diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index c792e8bb9..a9d2c0fa7 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1000,6 +1000,22 @@ unsafe fn object_assign_set_string_key( key_ptr: *const crate::StringHeader, value_f64: f64, ) { + // `Object.assign(process.env, parsed)` — how `@next/env` loads `.env` files. + // `process.env.X` READS lower to `js_getenv` (the real environment), so a + // field stored on the cached env object leaves every read `undefined`: a + // Next.js standalone server saw NONE of its `.env` config (myairank's + // `DATABASE_URL` vanished, mysql2 then connected with an empty user and the + // MySQL handshake timed out). Route the write through the env setter so it + // lands where the reads look. + // + // This hook lives at the single write funnel rather than as an early exit in + // `js_object_assign_one`, so every source shape still flows through the + // decoding below: a primitive/array/proxy source is enumerated correctly, + // and a nullish source is skipped per spec instead of throwing. + if !target_is_array && crate::process::is_process_env_ptr(target as usize) { + crate::process::js_setenv(key_ptr, value_f64); + return; + } if target_is_array { // Routes integer-index keys to array element-set (extending length); // non-numeric keys fall back to the object setter. @@ -1112,6 +1128,13 @@ unsafe fn object_assign_proxy_source( pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) -> f64 { let target_f64 = js_object_assign_validate_target(target_f64); + // NOTE: a `process.env` target is handled in `object_assign_set_string_key` + // (the single write funnel) rather than here. An early exit at this point + // would have to re-implement source decoding, and the version that did got + // all three edge cases wrong: it cast any source pointer to `ObjectHeader` + // (type confusion on a string/array source) and it enumerated the source + // with `js_object_keys_value`, which *throws* on `null`/`undefined` instead + // of skipping it as the spec requires. let target_value = JSValue::from_bits(target_f64.to_bits()); if !target_value.is_pointer() { return target_f64; diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index c7f24195c..fa80b0c35 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -33,16 +33,17 @@ pub use ipc::*; // ── env_misc re-exports (preserve `crate::process::*` paths) ──────────────── pub use env_misc::{ - js_getenv, js_getenv_value, js_process_abort, js_process_active_resources_info, - js_process_add_uncaught_exception_capture_callback, js_process_available_memory, - js_process_binding, js_process_chdir_jsv, js_process_constrained_memory, js_process_cpu_usage, - js_process_debug_end, js_process_debug_process, js_process_dlopen, js_process_emit_warning, - js_process_env, js_process_execve, js_process_exit, js_process_exit_code_get, - js_process_exit_code_set, js_process_fatal_exception, js_process_get_active_handles, - js_process_get_active_requests, js_process_has_uncaught_exception_capture_callback, - js_process_internal_kill, js_process_linked_binding, js_process_load_env_file, - js_process_memory_usage, js_process_open_stdin, js_process_raw_debug, js_process_really_exit, - js_process_ref, js_process_resource_usage, js_process_set_title, + is_process_env_object, is_process_env_ptr, js_getenv, js_getenv_value, js_process_abort, + js_process_active_resources_info, js_process_add_uncaught_exception_capture_callback, + js_process_available_memory, js_process_binding, js_process_chdir_jsv, + js_process_constrained_memory, js_process_cpu_usage, js_process_debug_end, + js_process_debug_process, js_process_dlopen, js_process_emit_warning, js_process_env, + js_process_execve, js_process_exit, js_process_exit_code_get, js_process_exit_code_set, + js_process_fatal_exception, js_process_get_active_handles, js_process_get_active_requests, + js_process_has_uncaught_exception_capture_callback, js_process_internal_kill, + js_process_linked_binding, js_process_load_env_file, js_process_memory_usage, + js_process_open_stdin, js_process_raw_debug, js_process_really_exit, js_process_ref, + js_process_resource_usage, js_process_set_title, js_process_set_uncaught_exception_capture_callback, js_process_start_profiler_idle_notifier, js_process_stop_profiler_idle_notifier, js_process_thread_cpu_usage, js_process_tick_callback, js_process_title, js_process_umask, js_process_umask_set, js_process_unref, js_removeenv, diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 01759eda2..cbf0cfca5 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -861,6 +861,9 @@ pub extern "C" fn js_setenv(name_ptr: *const StringHeader, value: f64) { Ok(s) => s, Err(_) => return, }; + if !env_name_is_settable(name) { + return; + } // Coerce value to string. js_jsvalue_to_string handles // numbers/booleans/null/undefined and returns a *mut StringHeader. @@ -883,6 +886,19 @@ pub extern "C" fn js_setenv(name_ptr: *const StringHeader, value: f64) { Err(_) => return, }; std::env::set_var(name, v_str); + // Keep the cached `process.env` object in step so enumeration + // (`Object.keys(process.env)`, `for…in`, spread) sees the new key — + // reads go through `js_getenv`, but enumeration walks this object. + let cached = CACHED_ENV.with(|c| c.get()); + if cached != 0.0 { + let obj = crate::value::js_nanbox_get_pointer(cached) as *mut crate::ObjectHeader; + if !obj.is_null() { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + let val = js_string_from_bytes(v_str.as_ptr(), v_str.len() as u32); + let val_f64 = f64::from_bits(JSValue::string_ptr(val).bits()); + crate::object::js_object_set_field_by_name(obj, key, val_f64); + } + } } } @@ -980,11 +996,50 @@ pub extern "C" fn js_removeenv(name_ptr: *const StringHeader) { /// it straight to subsequent PropertyGet dispatch. #[no_mangle] pub extern "C" fn js_process_env() -> f64 { - use std::cell::Cell; - ipc::process_ipc_ensure_initialized(); - thread_local! { - static CACHED_ENV: Cell = const { Cell::new(0.0) }; + js_process_env_impl() +} + +thread_local! { + static CACHED_ENV: std::cell::Cell = const { std::cell::Cell::new(0.0) }; +} + +/// Is `value` the live `process.env` object? Writes to it must reach the real +/// environment (`js_setenv`), not just the cached field bag: `process.env.X` +/// READS lower to `js_getenv`, so a field-only store is invisible. +/// `Object.assign(process.env, parsed)` is how `@next/env` loads `.env` files — +/// under Perry the keys landed in the object and every read still returned +/// `undefined`, so a Next.js standalone server saw NONE of its `.env` config +/// (myairank: `process.env.DATABASE_URL` undefined ⇒ mysql2 connected with an +/// empty user/database and the MySQL handshake timed out). +pub fn is_process_env_object(value: f64) -> bool { + let cached = CACHED_ENV.with(|c| c.get()); + cached != 0.0 && cached.to_bits() == value.to_bits() +} + +/// True when `addr` is the heap address of the cached `process.env` object. +/// +/// The pointer form of [`is_process_env_object`], for call sites that have +/// already unboxed the target (`Object.assign`'s write funnel). +pub fn is_process_env_ptr(addr: usize) -> bool { + let cached = CACHED_ENV.with(|c| c.get()); + if cached == 0.0 { + return false; } + crate::value::js_nanbox_get_pointer(cached) as usize == addr +} + +/// `std::env::set_var` PANICS — and, being called from an `extern "C"` frame, +/// aborts the process — when the name is empty, contains `=`, or contains a NUL +/// byte. `Object.assign(process.env, parsed)` feeds it arbitrary object keys, so +/// a single malformed key in a `.env` file would take the whole server down. +/// Node accepts such an assignment silently, so skip these names rather than +/// crash. +fn env_name_is_settable(name: &str) -> bool { + !name.is_empty() && !name.contains('=') && !name.contains('\0') +} + +fn js_process_env_impl() -> f64 { + ipc::process_ipc_ensure_initialized(); let cached = CACHED_ENV.with(|c| c.get()); if cached != 0.0 { return cached; diff --git a/test-files/test_gap_object_assign_process_env.ts b/test-files/test_gap_object_assign_process_env.ts new file mode 100644 index 000000000..af49e261f --- /dev/null +++ b/test-files/test_gap_object_assign_process_env.ts @@ -0,0 +1,62 @@ +// `Object.assign(process.env, parsed)` — how `@next/env` (and dotenv, and most +// config loaders) install a parsed `.env` file. `process.env` is not an ordinary +// object: reads go through the runtime's env lookup, so keys merged in by the +// generic object-assign path landed in a plain object that nothing ever consulted +// and every `process.env.X` read came back `undefined`. + +const parsed: Record = { + MY_APP_KEY: "abc123", + DATABASE_URL: "mysql://user:pw@localhost/db", +}; +Object.assign(process.env, parsed); + +console.log("direct read :", process.env.MY_APP_KEY); +console.log("bracket read :", process.env["DATABASE_URL"]); +console.log("in operator :", "MY_APP_KEY" in process.env); + +// a plain assignment must still work +process.env.SET_DIRECTLY = "yes"; +console.log("set directly :", process.env.SET_DIRECTLY); + +// assigning over an existing key +Object.assign(process.env, { MY_APP_KEY: "overwritten" }); +console.log("overwritten :", process.env.MY_APP_KEY); + +// a later read through a helper (not a direct member expression) +function readEnv(name: string): string | undefined { + return process.env[name]; +} +console.log("dynamic key :", readEnv("DATABASE_URL")); + +// multi-source assign +Object.assign(process.env, { A_ONE: "1" }, { A_TWO: "2" }); +console.log("multi-source :", process.env.A_ONE, process.env.A_TWO); + +// Object.assign onto an ordinary object must be unaffected +const plain: any = { a: 1 }; +const ret = Object.assign(plain, { b: 2 }, { c: 3 }); +console.log("plain object :", JSON.stringify(plain), ret === plain); + +// A `process.env` target must not make Object.assign lose the spec's handling +// of odd sources. The first implementation special-cased the env target at the +// TOP of js_object_assign_one and re-implemented source decoding there, which +// got all three of these wrong. + +// Nullish sources are SKIPPED, not an error (the env fast path enumerated them +// with js_object_keys_value, which throws ToObject's TypeError). +Object.assign(process.env, null); +Object.assign(process.env, undefined); +console.log("nullish source :", "ok"); + +// A primitive source exposes index keys. The fast path cast ANY source pointer +// to an ObjectHeader, so a string source was read through the wrong layout. +Object.assign(process.env, "ab"); +console.log("string source :", "ok"); + +// std::env::set_var PANICS (and, from an extern "C" frame, ABORTS the process) +// on a name that is empty or contains '=' or NUL. `Object.assign(process.env, +// parsed)` feeds it arbitrary keys, so one malformed line in a .env file used +// to take the whole server down. Node accepts these silently. +Object.assign(process.env, { "": "empty" }); +Object.assign(process.env, { "A=B": "equals" }); +console.log("odd env keys :", "ok");