fix(node): coerce process.env values to strings like Node.js

On non-Windows, process.env property assignments had no setter so raw
JS values were stored directly (e.g. env.FOO = undefined stored JS
undefined). On Windows the Proxy's set trap called String(value) which
coerces correctly but doesn't throw for Symbols.

Two fixes:
- Wire up jsSetterEnvironmentVariable (which calls JSValue::toString)
  as the setter for non-Windows CustomGetterSetter. JSValue::toString
  implements the ECMAScript ToString abstract op, so undefined/null/
  numbers coerce to strings and Symbols throw TypeError.
- Change String(value) to "" + value in the Windows Proxy setter so
  Symbol assignment throws TypeError there too (String(Symbol()) returns
  "Symbol(...)" but "" + Symbol() throws, matching Node.js behavior).

Result on all platforms:
  process.env.FOO = undefined  → "undefined"
  process.env.FOO = null       → "null"
  process.env.FOO = 123        → "123"
  process.env.FOO = Symbol()   → throws TypeError
This commit is contained in:
ant-kurt
2026-02-27 20:20:09 +00:00
parent 30e609e080
commit ce6e04c48b
3 changed files with 14 additions and 2 deletions

View File

@@ -305,7 +305,7 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject)
bool hasNodeTLSRejectUnauthorized = false;
bool hasBunConfigVerboseFetch = false;
auto* cached_getter_setter = JSC::CustomGetterSetter::create(vm, jsGetterEnvironmentVariable, nullptr);
auto* cached_getter_setter = JSC::CustomGetterSetter::create(vm, jsGetterEnvironmentVariable, jsSetterEnvironmentVariable);
for (size_t i = 0; i < count; i++) {
unsigned char* chars;

View File

@@ -393,7 +393,7 @@ export function windowsEnv(
set(_, p, value) {
const k = String(p).toUpperCase();
$assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now
value = String(value); // If toString() throws, we want to avoid it existing in the envMapList
value = "" + value; // If toString() throws, we want to avoid it existing in the envMapList
if (!(k in internalEnv) && !envMapList.includes(p)) {
envMapList.push(p);
}

View File

@@ -158,6 +158,18 @@ it("process.env", () => {
expect(process.env["LOL SMILE latin1 <abc>"]).toBe("<abc>");
delete process.env["LOL SMILE latin1 <abc>"];
expect(process.env["LOL SMILE latin1 <abc>"]).toBe(undefined);
// Node.js coerces non-string values to strings
process.env.BUN_TEST_ENV_COERCE = undefined;
expect(process.env.BUN_TEST_ENV_COERCE).toBe("undefined");
process.env.BUN_TEST_ENV_COERCE = null;
expect(process.env.BUN_TEST_ENV_COERCE).toBe("null");
process.env.BUN_TEST_ENV_COERCE = 123;
expect(process.env.BUN_TEST_ENV_COERCE).toBe("123");
delete process.env.BUN_TEST_ENV_COERCE;
// Symbol assignment should throw TypeError, matching Node.js
expect(() => { process.env.BUN_TEST_ENV_COERCE = Symbol("test"); }).toThrow(TypeError);
});
it("process.env is spreadable and editable", () => {