diff --git a/docs/guides/runtime/read-env.mdx b/docs/guides/runtime/read-env.mdx index 9a22fc9c7398..87325841ed9c 100644 --- a/docs/guides/runtime/read-env.mdx +++ b/docs/guides/runtime/read-env.mdx @@ -20,7 +20,7 @@ Bun.env.API_TOKEN; // => "secret" --- -To print all currently-set environment variables, run `bun --print process.env`. +To print all currently-set environment variables, run `bun --print process.env`. Values auto-loaded from `.env` files are non-enumerable and do not appear here; use `bun --print 'Object.getOwnPropertyNames(process.env)'` to list every key. ```sh terminal icon="terminal" bun --print process.env diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index 1680f0c0251b..77b5004f6103 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -139,6 +139,12 @@ 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. `Object.getOwnPropertyNames(process.env)` lists all keys including the auto-loaded ones. + +Default inherited environments (`Bun.spawn` with no `env`, `child_process` with no `options.env`, `Bun.$`, `worker_threads`, `cluster.fork`, and the WASI runner) still receive auto-loaded values. Once a `worker_threads` `SHARE_ENV` tree is founded, `process.env` becomes a shared string map and auto-loaded values enumerate like any other entry on the threads in that tree. + ## Reading environment variables Read the current environment variables from `process.env`. @@ -154,7 +160,7 @@ Bun.env.API_TOKEN; // => "secret" import.meta.env.API_TOKEN; // => "secret" ``` -To print all currently-set environment variables, run `bun --print process.env`. +To print all currently-set environment variables, run `bun --print process.env`. This lists variables from the OS environment and explicit `--env-file` arguments; values auto-loaded from `.env` files are [non-enumerable](#enumeration) and do not appear here. Use `bun --print 'Object.getOwnPropertyNames(process.env)'` to list every key including auto-loaded ones. ```sh bun --print process.env diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index b741cb9f2b47..83ea5c0c41bb 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -449,6 +449,7 @@ 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 @@ -456,7 +457,10 @@ impl Loader { .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(()) @@ -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 = Vec::new(); - Parser::parse_bytes::(str, &mut self.map, &mut value_buffer) + Parser::parse_bytes::( + str, + &mut self.map, + &mut value_buffer, + ) } pub fn load( @@ -870,7 +878,11 @@ impl Loader { } } ReadEnvFile::Bytes(buf) => { - Parser::parse_bytes::(&buf, &mut self.map, value_buffer)?; + Parser::parse_bytes::( + &buf, + &mut self.map, + value_buffer, + )?; } } @@ -917,7 +929,11 @@ impl Loader { } } ReadEnvFile::Bytes(buf) => { - Parser::parse_bytes::(&buf, &mut self.map, value_buffer)?; + Parser::parse_bytes::( + &buf, + &mut self.map, + value_buffer, + )?; } } @@ -1207,7 +1223,12 @@ impl<'a> Parser<'a> { Ok(Some(self.value_buffer.as_slice())) } - fn _parse( + fn _parse< + const OVERRIDE: bool, + const IS_PROCESS: bool, + const EXPAND: bool, + const CONDITIONAL: bool, + >( &mut self, map: &mut Map, ) -> Result<(), AllocError> { @@ -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 @@ -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, ¤t)? { - map.map.values_mut()[idx] = HashTableValue { - value: Box::from(expanded), - }; + map.map.values_mut()[idx].value = Box::from(expanded); } idx += 1; } @@ -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` without constructing a /// `bun_ast::Source` (whose `contents` field is currently `&'static [u8]`). - pub(crate) fn parse_bytes( + 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, @@ -1272,7 +1299,7 @@ impl<'a> Parser<'a> { src: strings::without_utf8_bom(src), value_buffer, }; - parser._parse::(map) + parser._parse::(map) } } @@ -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`. + pub conditional: bool, } // On Windows, environment variables are case-insensitive. So we use a case-insensitive hash map. @@ -1411,6 +1445,7 @@ impl Map { key, HashTableValue { value: Box::from(value), + conditional: false, }, ) } @@ -1428,6 +1463,7 @@ impl Map { key, HashTableValue { value: Box::from(value), + conditional: false, }, ); } @@ -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); @@ -1471,6 +1508,7 @@ impl Map { key, HashTableValue { value: Box::from(value), + conditional: false, }, )?; Ok(()) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 78ec6d7a0ea3..7ed2e2d038ef 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -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, }; } diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index 6fe84e33ed13..9b38c4cfe08b 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -71,6 +71,7 @@ impl IniTestingAPIs { &keyslice, dotenv::map::Entry { value: slice.into_boxed_slice(), + conditional: false, }, )?; } diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 37681a67f7c6..0870e4576fcf 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -453,24 +453,21 @@ export function windowsEnv( // // it throws "Cannot convert a Symbol value to a string" - (internalEnv as any)[Bun.inspect.custom] = () => { + 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. + (internalEnv as any).toJSON = enumerableView; return new Proxy(internalEnv, { get(_, p) { @@ -505,8 +502,10 @@ export function windowsEnv( } 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. + internalEnv[k] = value; return true; }, has(_, p) { @@ -530,7 +529,10 @@ export function windowsEnv( 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`: the + // always-present TZ/proxy accessors are own properties of internalEnv + // while correctly absent from envMapList. + if (!envMapList.includes(p) && !envMapList.some(x => x.toUpperCase() === k)) { envMapList.push(p); } editWindowsEnvVar(k, internalEnv[k]); diff --git a/src/js/builtins/shell.ts b/src/js/builtins/shell.ts index f1d19bb84acd..b00fd8262288 100644 --- a/src/js/builtins/shell.ts +++ b/src/js/builtins/shell.ts @@ -161,7 +161,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa newEnv = defaultEnv; } - this.#args!.setEnv(newEnv); + this.#args!.setEnv(newEnv === originalDefaultEnv ? snapshotProcessEnv(newEnv) : newEnv); return this; } @@ -253,6 +253,15 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa const originalDefaultEnv = defaultEnv; var defaultCwd: string | undefined = undefined; + function snapshotProcessEnv(env) { + const out = {}; + for (const key of $Object.getOwnPropertyNames(env)) { + const v = env[key]; + if (v !== undefined && typeof v !== "function") out[key] = v; + } + return out; + } + const cwdSymbol = Symbol("cwd"); const envSymbol = Symbol("env"); const throwsSymbol = Symbol("throws"); @@ -309,7 +318,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa // cwd must be set before env or else it will be injected into env as "PWD=/" if (cwd) parsed_shell_script.setCwd(cwd); - if (env) parsed_shell_script.setEnv(env); + if (env) parsed_shell_script.setEnv(env === originalDefaultEnv ? snapshotProcessEnv(env) : env); return new ShellPromise(parsed_shell_script, throws); }; @@ -329,7 +338,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa // cwd must be set before env or else it will be injected into env as "PWD=/" if (cwd) parsed_shell_script.setCwd(cwd); - if (env) parsed_shell_script.setEnv(env); + if (env) parsed_shell_script.setEnv(env === originalDefaultEnv ? snapshotProcessEnv(env) : env); return new ShellPromise(parsed_shell_script, throws); }; diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts index 88541202b8c7..e3ab5ce74073 100644 --- a/src/js/internal/cluster/primary.ts +++ b/src/js/internal/cluster/primary.ts @@ -78,7 +78,12 @@ function setupSettingsNT(settings) { } function createWorkerProcess(id, env) { - const workerEnv = { ...process.env, ...env, NODE_UNIQUE_ID: `${id}` }; + const workerEnv = {}; + for (const k of $Object.getOwnPropertyNames(process.env)) { + const v = process.env[k]; + if (v !== undefined && typeof v !== "function") workerEnv[k] = v; + } + Object.assign(workerEnv, env, { NODE_UNIQUE_ID: `${id}` }); const execArgv = [...cluster.settings.execArgv]; // if (cluster.settings.inspectPort === null) { diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 50b9264d2f5e..5aa37710cec4 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1020,8 +1020,14 @@ function normalizeSpawnArguments(file, args, options) { // copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env); let envKeys: string[] = []; - for (const key in env) { - ArrayPrototypePush.$call(envKeys, key); + if (env === process.env) { + for (const key of $Object.getOwnPropertyNames(env)) { + ArrayPrototypePush.$call(envKeys, key); + } + } else { + for (const key in env) { + ArrayPrototypePush.$call(envKeys, key); + } } if (process.platform === "win32") { diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 75abf5fb79e3..fddf9b602f32 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -473,10 +473,23 @@ async function runOneFile( reporter.enqueue({ __proto__: null, ...fileNode }); reporter.dequeue({ __proto__: null, ...fileNode }); + const baseEnv: Record = {}; + const optsEnv = opts.env; + if (optsEnv) { + Object.assign(baseEnv, optsEnv); + } else { + for (const k of $Object.getOwnPropertyNames(process.env)) { + const v = process.env[k]; + if (v !== undefined && typeof v !== "function") baseEnv[k] = v; + } + } + baseEnv.BUN_TEST_DRAIN_EVENT_LOOP = "1"; + baseEnv[kRunChildEnv] = kRunChildEnvValue; + const proc = Bun.spawn({ cmd: args, cwd: opts.cwd as string, - env: { ...(opts.env ?? process.env), BUN_TEST_DRAIN_EVENT_LOOP: "1", [kRunChildEnv]: kRunChildEnvValue }, + env: baseEnv, stdout: "pipe", stderr: "pipe", signal: opts.signal, diff --git a/src/js/wasi-runner.js b/src/js/wasi-runner.js index 5e7cb48cdb47..92d570818a9a 100644 --- a/src/js/wasi-runner.js +++ b/src/js/wasi-runner.js @@ -19,7 +19,11 @@ var { WASM_USE_ASYNC_INIT = "1", } = process.env; -var env = process.env; +var env = {}; +for (const k of Object.getOwnPropertyNames(process.env)) { + const v = process.env[k]; + if (v !== undefined && typeof v !== "function") env[k] = v; +} if (WASM_ENV_STR?.length) { env = JSON.parse(WASM_ENV_STR); } diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index fc29c4c824fa..f924da1b43d1 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -28,6 +28,7 @@ using namespace JSC; 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); @@ -61,6 +62,32 @@ JSC_DEFINE_CUSTOM_GETTER(jsGetterEnvironmentVariable, (JSGlobalObject * globalOb 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). +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(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(); @@ -694,8 +721,11 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb RETURN_IF_EXCEPTION(scope, nullptr); } + // Include DontEnum so auto-loaded .env values (conditional CustomAccessors) + // and the always-present TZ/TLS/proxy accessors are seeded; values that read + // as undefined or callable are filtered below. JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); - envObject->methodTable()->getOwnPropertyNames(envObject, globalObject, keys, JSC::DontEnumPropertiesMode::Exclude); + envObject->methodTable()->getOwnPropertyNames(envObject, globalObject, keys, JSC::DontEnumPropertiesMode::Include); RETURN_IF_EXCEPTION(scope, nullptr); // Seed unconditionally: this thread's env is the new tree's initial contents. @@ -703,6 +733,9 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb for (const auto& key : keys) { JSValue value = envObject->get(globalObject, key); RETURN_IF_EXCEPTION(scope, nullptr); + // DontEnum accessors for unset special vars (TZ, TLS, proxy) read undefined. + if (value.isUndefined()) + continue; // Windows' process.env Proxy owns an enumerable `toJSON`; it is not an env var. if (value.isCallable()) continue; @@ -759,7 +792,7 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) } #if OS(WINDOWS) - JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count); + JSArray* keyArray = constructEmptyArray(globalObject, nullptr, 0); RETURN_IF_EXCEPTION(scope, {}); #endif @@ -795,30 +828,40 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) }; 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. Include conditional + // keys so Object.getOwnPropertyNames sees them; Object.keys / for..in / + // spread still filter them out via the getOwnPropertyDescriptor trap, + // which reflects internalEnv's DontEnum accessor. + keyArray->push(globalObject, jsString(vm, name)); + RETURN_IF_EXCEPTION(scope, {}); #endif + // The has* flags gate whether the post-loop CustomAccessor is installed + // enumerable; a .env-only value (conditional) stays DontEnum by leaving + // the flag false. if (name == TZ) { - hasTZ = true; + if (!conditional) hasTZ = true; continue; } if (name == NODE_TLS_REJECT_UNAUTHORIZED) { - hasNodeTLSRejectUnauthorized = true; + if (!conditional) hasNodeTLSRejectUnauthorized = true; continue; } if (name == BUN_CONFIG_VERBOSE_FETCH) { - hasBunConfigVerboseFetch = true; + if (!conditional) hasBunConfigVerboseFetch = true; continue; } if (auto idx = isProxyVar(name)) { - hasProxyVar[*idx] = true; + if (!conditional) hasProxyVar[*idx] = true; continue; } ASSERT(len > 0); @@ -838,7 +881,8 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) if (Bun__getEnvValue(globalObject, &nameStr, &valueString)) { JSValue value = jsString(vm, Zig::toStringCopy(valueString)); RETURN_IF_EXCEPTION(scope, {}); - object->putDirectIndex(globalObject, *index, value, 0, PutDirectIndexLikePutDirect); + unsigned indexAttrs = conditional ? static_cast(JSC::PropertyAttribute::DontEnum) : 0; + object->putDirectIndex(globalObject, *index, value, indexAttrs, PutDirectIndexLikePutDirect); RETURN_IF_EXCEPTION(scope, {}); } continue; @@ -849,7 +893,18 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) // 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. + if (conditional) { + object->putDirectCustomAccessor(vm, identifier, conditional_getter_setter, + JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DontEnum | 0); + } else { + object->putDirectCustomAccessor(vm, identifier, cached_getter_setter, + JSC::PropertyAttribute::CustomValue | 0); + } } unsigned int TZAttrs = JSC::PropertyAttribute::CustomAccessor | 0; diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 0e94c9adb507..7343908e9097 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -267,21 +267,34 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: return Bun::ERR::INVALID_ARG_TYPE(throwScope, globalObject, "options.env"_s, "object or one of undefined, null, or worker_threads.SHARE_ENV"_s, envValue); } JSObject* envObject = nullptr; + bool isProcessEnv = false; if (envValue && envValue.isCell()) { envObject = dynamicDowncast(envValue); + isProcessEnv = globalObject->m_processEnvObject.isInitialized() + && envObject == globalObject->processEnvObject(); } else if (globalObject->m_processEnvObject.isInitialized()) { envObject = globalObject->processEnvObject(); + isProcessEnv = true; } if (envObject) { + // process.env carries DontEnum accessors for auto-loaded .env + // values and the always-present TZ/TLS/proxy vars; include + // DontEnum when snapshotting it so workers keep seeing .env + // values. On Windows the Proxy's ownKeys trap lists those keys + // in their original case and the descriptor trap reports them + // non-enumerable, so Include sees them without an unwrap. + // User-provided env objects stay Exclude so their DontEnum + // properties are not leaked. if (!envObject->staticPropertiesReified()) { envObject->reifyAllStaticProperties(globalObject); RETURN_IF_EXCEPTION(throwScope, {}); } JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); - envObject->methodTable()->getOwnPropertyNames(envObject, lexicalGlobalObject, keys, JSC::DontEnumPropertiesMode::Exclude); + envObject->methodTable()->getOwnPropertyNames(envObject, lexicalGlobalObject, keys, + isProcessEnv ? JSC::DontEnumPropertiesMode::Include : JSC::DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, {}); HashMap env; @@ -289,6 +302,12 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: for (const auto& key : keys) { JSValue value = envObject->get(lexicalGlobalObject, key); RETURN_IF_EXCEPTION(throwScope, {}); + if (isProcessEnv) { + if (value.isUndefined()) + continue; + if (value.isCallable()) + continue; + } String str = value.toWTFString(lexicalGlobalObject).isolatedCopy(); RETURN_IF_EXCEPTION(throwScope, {}); env.add(key.impl()->isolatedCopy(), str); diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 6c3911db557a..033fd84d9fc0 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -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. + #[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, diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index c3d0481ee43a..f5e4ab22f219 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -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, }; } diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index 78b29adcc02b..445dd3587995 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -222,6 +222,221 @@ describe("dotenv priority", () => { const { stdout: stdout_test } = bunTest(`${dir}/index.test.ts`, {}); expect(stdout_test).toBe(`bun test ${Bun.version_with_sha}\n` + ".env.test"); }); + + // https://github.com/oven-sh/bun/issues/6338 + test("auto-loaded .env values are not enumerable on process.env", () => { + const dir = tempDirWithFiles("dotenv-enum", { + ".env": "AUTO_FROM_FILE=from-file\nBOTH=from-file\n", + "index.ts": ` + const d = Object.getOwnPropertyDescriptor(process.env, "AUTO_FROM_FILE"); + console.log(JSON.stringify({ + read: process.env.AUTO_FROM_FILE, + inOp: "AUTO_FROM_FILE" in process.env, + hasOwn: Object.hasOwn(process.env, "AUTO_FROM_FILE"), + enumerable: d?.enumerable, + keys: Object.keys(process.env).includes("AUTO_FROM_FILE"), + spread: "AUTO_FROM_FILE" in { ...process.env }, + both: Object.getOwnPropertyDescriptor(process.env, "BOTH")?.enumerable, + })); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`, { BOTH: "from-process" }); + expect(JSON.parse(stdout)).toEqual({ + read: "from-file", + inOp: true, + hasOwn: true, + enumerable: false, + keys: false, + spread: false, + // BOTH came from the OS env, so it stays enumerable even though .env also defines it. + both: true, + }); + }); + + // https://github.com/oven-sh/bun/issues/6338 + test("auto-loaded .env values do not shadow mode-specific dotenv loaders", () => { + // Mirrors Vite's loadEnv(): parse .env.{mode} then let enumerable + // process.env keys override. Under Node process.env has no .env entries, + // so .env.production wins; Bun must behave the same. + const dir = tempDirWithFiles("dotenv-loadEnv", { + ".env": "PUBLICPATH=/\n", + ".env.production": "PUBLICPATH=/app\n", + "index.ts": ` + import fs from "fs"; + import path from "path"; + const parsed: Record = {}; + for (const f of [".env", ".env.production"]) { + for (const line of fs.readFileSync(path.join(process.cwd(), f), "utf8").split("\\n")) { + const m = line.match(/^([^=]+)=(.*)$/); + if (m) parsed[m[1]] = m[2]; + } + } + const processEnv = { ...process.env }; + for (const key of Object.keys(parsed)) { + if (processEnv[key] !== undefined) parsed[key] = processEnv[key]!; + } + for (const key in process.env) { + if (key in parsed) parsed[key] = process.env[key]!; + } + console.log(parsed.PUBLICPATH); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("/app"); + }); + + test("writing to an auto-loaded .env key makes it enumerable", () => { + const dir = tempDirWithFiles("dotenv-write", { + ".env": "AUTO_FROM_FILE=from-file\n", + "index.ts": ` + console.log(Object.keys(process.env).includes("AUTO_FROM_FILE")); + process.env.AUTO_FROM_FILE = "from-js"; + console.log(Object.keys(process.env).includes("AUTO_FROM_FILE")); + console.log(process.env.AUTO_FROM_FILE); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("false\ntrue\nfrom-js"); + }); + + test("--env-file values stay enumerable on process.env", () => { + const dir = tempDirWithFiles("dotenv-explicit", { + ".env.custom": "EXPLICIT_FROM_FILE=1\n", + "index.ts": `console.log(Object.keys(process.env).includes("EXPLICIT_FROM_FILE"));`, + }); + const result = Bun.spawnSync([bunExe(), "--env-file", ".env.custom", "index.ts"], { + cwd: dir, + env: { ...bunEnv, NODE_ENV: undefined }, + }); + expect(result.stdout.toString("utf8").trim()).toBe("true"); + }); + + // Worker/subprocess-spawning tests below are slow under debug+ASAN. + const spawnTimeout = (isDebug || isASAN ? 6 : 1) * 5000; + + test( + "auto-loaded .env values survive founding a SHARE_ENV worker tree", + () => { + const dir = tempDirWithFiles("dotenv-share-env", { + ".env": "AUTO_FROM_FILE=secret\n", + "worker.js": `process.exit(0);`, + "index.ts": ` + const { Worker, SHARE_ENV } = require("worker_threads"); + console.log(process.env.AUTO_FROM_FILE); + const w = new Worker("./worker.js", { env: SHARE_ENV }); + w.on("exit", () => { + console.log(process.env.AUTO_FROM_FILE); + process.exit(0); + }); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("secret\nsecret"); + }, + spawnTimeout, + ); + + test( + "auto-loaded .env values survive child_process default env inheritance", + () => { + const dir = tempDirWithFiles("dotenv-cp", { + ".env": "AUTO_FROM_FILE=secret\n", + "sub/child.js": ` + // enumerable=true iff the value arrived via the OS env block; false if + // the child re-auto-loaded it from a .env file in cwd. + const d = Object.getOwnPropertyDescriptor(process.env, "AUTO_FROM_FILE"); + console.log(process.env.AUTO_FROM_FILE, d?.enumerable, process.env.USER_MUTATION); + `, + "index.ts": ` + process.env.USER_MUTATION = "from-js"; + const { execFileSync } = require("child_process"); + const out = execFileSync(process.execPath, ["child.js"], { cwd: "sub", encoding: "utf8" }); + console.log(out.trim()); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("secret true from-js"); + }, + spawnTimeout, + ); + + test("auto-loaded .env values survive Bun.$ default env inheritance", () => { + const dir = tempDirWithFiles("dotenv-shell", { + ".env": "AUTO_FROM_FILE=secret\n", + "index.ts": ` + process.env.USER_MUTATION = "from-js"; + const echo = await Bun.$\`echo \${{raw: "$AUTO_FROM_FILE $USER_MUTATION"}}\`.text(); + console.log(echo.trim()); + const reset = await Bun.$\`echo \${{raw: "$AUTO_FROM_FILE"}}\`.env(undefined).text(); + console.log(reset.trim()); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("secret from-js\nsecret"); + }); + + test( + "auto-loaded .env values survive cluster.fork default env inheritance", + () => { + const dir = tempDirWithFiles("dotenv-cluster", { + ".env": "AUTO_FROM_FILE=secret\n", + "sub/.keep": "", + "index.ts": ` + const cluster = require("cluster"); + if (cluster.isPrimary) { + cluster.setupPrimary({ cwd: "sub", exec: __filename }); + cluster.fork().on("exit", () => process.exit(0)); + } else { + const d = Object.getOwnPropertyDescriptor(process.env, "AUTO_FROM_FILE"); + console.log(process.env.AUTO_FROM_FILE, d?.enumerable); + process.exit(0); + } + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("secret true"); + }, + spawnTimeout, + ); + + test( + "auto-loaded .env values survive the default worker env snapshot", + () => { + const dir = tempDirWithFiles("dotenv-worker-snap", { + ".env": "AUTO_FROM_FILE=secret\n", + "worker.js": `require("worker_threads").parentPort.postMessage(process.env.AUTO_FROM_FILE);`, + "index.ts": ` + const { Worker } = require("worker_threads"); + void process.env.PATH; + const w = new Worker("./worker.js", {}); + w.on("message", (m) => { console.log(process.env.AUTO_FROM_FILE, m); process.exit(0); }); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`); + expect(stdout).toBe("secret secret"); + }, + spawnTimeout, + ); + + test("auto-loaded special-cased env keys are not enumerable", () => { + const dir = tempDirWithFiles("dotenv-special", { + ".env": "HTTP_PROXY=http://p:1\nTZ=UTC\n123=num\n", + "index.ts": ` + const keys = Object.keys(process.env); + console.log(JSON.stringify({ + proxy: { read: process.env.HTTP_PROXY, listed: keys.includes("HTTP_PROXY") }, + tz: { read: process.env.TZ, listed: keys.includes("TZ") }, + num: { read: process.env[123], listed: keys.includes("123") }, + })); + `, + }); + const { stdout } = bunRun(`${dir}/index.ts`, { HTTP_PROXY: undefined, TZ: undefined }); + expect(JSON.parse(stdout)).toEqual({ + proxy: { read: "http://p:1", listed: false }, + tz: { read: "UTC", listed: false }, + num: { read: "num", listed: false }, + }); + }); }); test(".env colon assign", () => { @@ -633,11 +848,21 @@ describe("--env-file", () => { test("when arg missing, fallback to default dotenv behavior", () => { // if --env-file missing, it should fallback to the default builtin behavior (.env, .env.production, etc.) - expect(bunRun([]).stdout).toBe("BUNTEST_DOTENV=1"); + // auto-loaded .env values are non-enumerable (see #6338), so check via direct access rather than Object.entries. + const result = Bun.spawnSync([bunExe(), "-e", "console.log(process.env.BUNTEST_DOTENV)"], { + cwd: dir, + env: { ...bunEnv, NODE_ENV: undefined }, + }); + expect(result.stdout.toString("utf8").trim()).toBe("1"); }); test("empty string disables default dotenv behavior", () => { - expect(bunRun(["--env-file=''"]).stdout).toBe(""); + // auto-loaded .env values are non-enumerable (see #6338), so check via direct access rather than Object.entries. + const result = Bun.spawnSync([bunExe(), "--env-file=''", "-e", "console.log(process.env.BUNTEST_DOTENV)"], { + cwd: dir, + env: { ...bunEnv, NODE_ENV: undefined }, + }); + expect(result.stdout.toString("utf8").trim()).toBe("undefined"); }); test("should correctly ignore invalid values and parse the rest", () => { @@ -646,8 +871,12 @@ describe("--env-file", () => { }); test("should ignore a file that doesn't exist", () => { - const res = bunRun(["--env-file=.env.nonexisting"]); - expect(res.stdout).toBe(""); + // auto-loaded .env values are non-enumerable (see #6338), so check via direct access rather than Object.entries. + const result = Bun.spawnSync( + [bunExe(), "--env-file=.env.nonexisting", "-e", "console.log(process.env.BUNTEST_DOTENV)"], + { cwd: dir, env: { ...bunEnv, NODE_ENV: undefined } }, + ); + expect(result.stdout.toString("utf8").trim()).toBe("undefined"); }); });