diff --git a/lib/child_process.js b/lib/child_process.js index 0e3e04af0d6..9f7d105c31f 100644 --- a/lib/child_process.js +++ b/lib/child_process.js @@ -40,6 +40,7 @@ const { RegExpPrototypeExec, SafeSet, StringPrototypeIncludes, + StringPrototypeIndexOf, StringPrototypeSlice, StringPrototypeToUpperCase, SymbolDispose, @@ -93,6 +94,8 @@ const { stdioStringToArray, } = child_process; +const { getEnvPairs } = internalBinding('process_wrap'); + const MAX_BUFFER = 1024 * 1024; const permission = require('internal/process/permission'); @@ -697,60 +700,74 @@ function normalizeSpawnArguments(file, args, options) { ArrayPrototypeUnshift(args, file); } - // Shallow copy to guarantee changes won't impact process.env - const env = options.env || { ...process.env }; - const envPairs = []; - - // process.env.NODE_V8_COVERAGE always propagates, making it possible to - // collect coverage for programs that spawn with white-listed environment. - copyProcessEnvToEnv(env, 'NODE_V8_COVERAGE', options.env); - - if (isZOS) { - // The following environment variables must always propagate if set. - copyProcessEnvToEnv(env, '_BPXK_AUTOCVT', options.env); - copyProcessEnvToEnv(env, '_CEE_RUNOPTS', options.env); - copyProcessEnvToEnv(env, '_TAG_REDIR_ERR', options.env); - copyProcessEnvToEnv(env, '_TAG_REDIR_IN', options.env); - copyProcessEnvToEnv(env, '_TAG_REDIR_OUT', options.env); - copyProcessEnvToEnv(env, 'STEPLIB', options.env); - copyProcessEnvToEnv(env, 'LIBPATH', options.env); - copyProcessEnvToEnv(env, '_EDC_SIG_DFLT', options.env); - copyProcessEnvToEnv(env, '_EDC_SUSV3', options.env); - } + let envPairs; + if (!options.env && !permission.isEnabled()) { + // Default: the child inherits this process's environment. Take it as + // 'KEY=value' strings in one native pass over the environment block + // instead of copying process.env (one interceptor round trip and one + // getenv() scan per variable). Everything copyProcessEnvToEnv() would + // propagate below is part of it by definition, and entries of the real + // environment block cannot contain null bytes. + envPairs = getEnvPairs(); + if (process.platform === 'win32') { + envPairs = dedupeWindowsEnvPairs(envPairs); + } + } else { + // Shallow copy to guarantee changes won't impact process.env + const env = options.env || { ...process.env }; + envPairs = []; + + // process.env.NODE_V8_COVERAGE always propagates, making it possible to + // collect coverage for programs that spawn with white-listed environment. + copyProcessEnvToEnv(env, 'NODE_V8_COVERAGE', options.env); + + if (isZOS) { + // The following environment variables must always propagate if set. + copyProcessEnvToEnv(env, '_BPXK_AUTOCVT', options.env); + copyProcessEnvToEnv(env, '_CEE_RUNOPTS', options.env); + copyProcessEnvToEnv(env, '_TAG_REDIR_ERR', options.env); + copyProcessEnvToEnv(env, '_TAG_REDIR_IN', options.env); + copyProcessEnvToEnv(env, '_TAG_REDIR_OUT', options.env); + copyProcessEnvToEnv(env, 'STEPLIB', options.env); + copyProcessEnvToEnv(env, 'LIBPATH', options.env); + copyProcessEnvToEnv(env, '_EDC_SIG_DFLT', options.env); + copyProcessEnvToEnv(env, '_EDC_SUSV3', options.env); + } - if (permission.isEnabled()) { - copyPermissionModelFlagsToEnv(env, 'NODE_OPTIONS', args); - } + if (permission.isEnabled()) { + copyPermissionModelFlagsToEnv(env, 'NODE_OPTIONS', args); + } - let envKeys = []; - // Prototype values are intentionally included. - for (const key in env) { - ArrayPrototypePush(envKeys, key); - } + let envKeys = []; + // Prototype values are intentionally included. + for (const key in env) { + ArrayPrototypePush(envKeys, key); + } - if (process.platform === 'win32') { - // On Windows env keys are case insensitive. Filter out duplicates, - // keeping only the first one (in lexicographic order) - const sawKey = new SafeSet(); - envKeys = ArrayPrototypeFilter( - ArrayPrototypeSort(envKeys), - (key) => { - const uppercaseKey = StringPrototypeToUpperCase(key); - if (sawKey.has(uppercaseKey)) { - return false; - } - sawKey.add(uppercaseKey); - return true; - }, - ); - } + if (process.platform === 'win32') { + // On Windows env keys are case insensitive. Filter out duplicates, + // keeping only the first one (in lexicographic order) + const sawKey = new SafeSet(); + envKeys = ArrayPrototypeFilter( + ArrayPrototypeSort(envKeys), + (key) => { + const uppercaseKey = StringPrototypeToUpperCase(key); + if (sawKey.has(uppercaseKey)) { + return false; + } + sawKey.add(uppercaseKey); + return true; + }, + ); + } - for (const key of envKeys) { - const value = env[key]; - if (value !== undefined) { - validateArgumentNullCheck(key, `options.env['${key}']`); - validateArgumentNullCheck(value, `options.env['${key}']`); - ArrayPrototypePush(envPairs, `${key}=${value}`); + for (const key of envKeys) { + const value = env[key]; + if (value !== undefined) { + validateArgumentNullCheck(key, `options.env['${key}']`); + validateArgumentNullCheck(value, `options.env['${key}']`); + ArrayPrototypePush(envPairs, `${key}=${value}`); + } } } @@ -768,6 +785,34 @@ function normalizeSpawnArguments(file, args, options) { }; } +/** + * Windows environment variable names are case-insensitive: keep only the + * first entry for each name in lexicographic order of the names, exactly like + * the key filtering applied to a user-supplied `options.env`. + * @param {string[]} envPairs 'KEY=value' strings + * @returns {string[]} + */ +function dedupeWindowsEnvPairs(envPairs) { + const keyed = []; + for (let i = 0; i < envPairs.length; i++) { + const pair = envPairs[i]; + // Names never start with '=' here (hidden variables are not enumerated), + // so the first '=' ends the name. + const eq = StringPrototypeIndexOf(pair, '='); + ArrayPrototypePush(keyed, { key: eq === -1 ? pair : StringPrototypeSlice(pair, 0, eq), pair }); + } + ArrayPrototypeSort(keyed, (a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + const sawKey = new SafeSet(); + const result = []; + for (let i = 0; i < keyed.length; i++) { + const uppercaseKey = StringPrototypeToUpperCase(keyed[i].key); + if (sawKey.has(uppercaseKey)) continue; + sawKey.add(uppercaseKey); + ArrayPrototypePush(result, keyed[i].pair); + } + return result; +} + function abortChildProcess(child, killSignal, reason) { if (!child) return; diff --git a/src/node_env_var.cc b/src/node_env_var.cc index e94180cd659..453043ef3f3 100644 --- a/src/node_env_var.cc +++ b/src/node_env_var.cc @@ -15,6 +15,7 @@ using v8::Boolean; using v8::Context; using v8::DontDelete; using v8::DontEnum; +using v8::EscapableHandleScope; using v8::FunctionTemplate; using v8::HandleScope; using v8::IndexedPropertyHandlerConfiguration; @@ -47,6 +48,7 @@ class RealEnvStore final : public KVStore { int32_t Query(const char* key) const override; void Delete(Isolate* isolate, Local key) override; MaybeLocal Enumerate(Isolate* isolate) const override; + MaybeLocal Pairs(Isolate* isolate) const override; }; class MapKVStore final : public KVStore { @@ -219,6 +221,62 @@ MaybeLocal RealEnvStore::Enumerate(Isolate* isolate) const { return Array::New(isolate, env_v.out(), env_v_index); } +MaybeLocal RealEnvStore::Pairs(Isolate* isolate) const { + Mutex::ScopedLock lock(per_process::env_var_mutex); + uv_env_item_t* items; + int count; + + auto cleanup = OnScopeLeave([&]() { uv_os_free_environ(items, count); }); + CHECK_EQ(uv_os_environ(&items, &count), 0); + + MaybeStackBuffer, 256> pairs_v(count); + int pairs_v_index = 0; + std::string pair; + for (int i = 0; i < count; i++) { +#ifdef _WIN32 + // If the key starts with '=' it is a hidden environment variable. + // Enumerate() skips these, so a copy of process.env never had them. + if (items[i].name[0] == '=') continue; +#endif + pair.assign(items[i].name); + pair += '='; + pair += items[i].value; + Local str; + if (!ToV8Value(isolate->GetCurrentContext(), pair, isolate).ToLocal(&str)) { + return {}; + } + pairs_v[pairs_v_index++] = str; + } + + return Array::New(isolate, pairs_v.out(), pairs_v_index); +} + +MaybeLocal KVStore::Pairs(Isolate* isolate) const { + EscapableHandleScope scope(isolate); + Local context = isolate->GetCurrentContext(); + Local keys; + if (!Enumerate(isolate).ToLocal(&keys)) return {}; + uint32_t keys_length = keys->Length(); + LocalVector pairs(isolate); + pairs.reserve(keys_length); + for (uint32_t i = 0; i < keys_length; i++) { + Local key; + Local value; + if (!keys->Get(context, i).ToLocal(&key)) return {}; + if (!key->IsString()) continue; + // A key that disappeared between Enumerate() and Get() is skipped, like an + // undefined value is when copying process.env in JS. + if (!Get(isolate, key.As()).ToLocal(&value)) continue; + Local pair = String::Concat( + isolate, + String::Concat( + isolate, key.As(), FIXED_ONE_BYTE_STRING(isolate, "=")), + value); + pairs.push_back(pair); + } + return scope.Escape(Array::New(isolate, pairs.data(), pairs.size())); +} + std::shared_ptr KVStore::Clone(Isolate* isolate) const { HandleScope handle_scope(isolate); Local context = isolate->GetCurrentContext(); diff --git a/src/process_wrap.cc b/src/process_wrap.cc index 21ccb2a9989..b7d34497c23 100644 --- a/src/process_wrap.cc +++ b/src/process_wrap.cc @@ -79,6 +79,7 @@ class ProcessWrap : public HandleWrap { SetProtoMethod(isolate, constructor, "kill", Kill); SetConstructorFunction(context, target, "Process", constructor); + SetMethodNoSideEffect(context, target, "getEnvPairs", GetEnvPairs); Local constants = Object::New(isolate); NODE_DEFINE_CONSTANT(constants, kProcessFlagDetached); @@ -91,6 +92,7 @@ class ProcessWrap : public HandleWrap { registry->Register(New); registry->Register(Spawn); registry->Register(Kill); + registry->Register(GetEnvPairs); } SET_NO_MEMORY_INFO() @@ -341,6 +343,17 @@ class ProcessWrap : public HandleWrap { args.GetReturnValue().Set(err); } + // The current environment as ["KEY=value", ...], i.e. what a spawned + // child inherits by default, produced in one pass over the environment + // block instead of one interceptor round trip per variable. + static void GetEnvPairs(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Local pairs; + if (env->env_vars()->Pairs(env->isolate()).ToLocal(&pairs)) { + args.GetReturnValue().Set(pairs); + } + } + static void Kill(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); ProcessWrap* wrap; diff --git a/src/util.h b/src/util.h index 0461a5ebb19..4f272d26bd9 100644 --- a/src/util.h +++ b/src/util.h @@ -323,6 +323,10 @@ class KVStore { virtual int32_t Query(const char* key) const = 0; virtual void Delete(v8::Isolate* isolate, v8::Local key) = 0; virtual v8::MaybeLocal Enumerate(v8::Isolate* isolate) const = 0; + // All entries as an array of "KEY=value" strings, in enumeration order — + // the form uv_spawn() consumes. The default implementation is + // Enumerate() + Get(); stores that can produce it in one pass override it. + virtual v8::MaybeLocal Pairs(v8::Isolate* isolate) const; virtual std::shared_ptr Clone(v8::Isolate* isolate) const; virtual v8::Maybe AssignFromObject(v8::Local context, diff --git a/test/parallel/test-child-process-default-env.js b/test/parallel/test-child-process-default-env.js new file mode 100644 index 00000000000..e9c111dd13e --- /dev/null +++ b/test/parallel/test-child-process-default-env.js @@ -0,0 +1,75 @@ +'use strict'; +// When no `env` option is given, a child process must inherit exactly the +// parent's current environment: same variables, same values, reflecting +// runtime additions/deletions made through process.env, and (on POSIX, where +// nothing re-sorts the block) in the same order the parent enumerates it. +const common = require('../common'); +const assert = require('assert'); +const { spawn, spawnSync, execFileSync } = require('child_process'); + +// Mutate the environment at runtime in a few ways first. +process.env.TEST_DEFAULT_ENV_ADDED = 'added ünïcödé ✓'; +process.env.TEST_DEFAULT_ENV_EMPTY = ''; +process.env.TEST_DEFAULT_ENV_EQUALS = 'a=b=c'; +process.env.TEST_DEFAULT_ENV_DELETED = 'x'; +delete process.env.TEST_DEFAULT_ENV_DELETED; + +function expectedEnv() { + // What `{ ...process.env }` yields, minus keys whose value is undefined + // (there are none for the real environment, but keep the definition exact). + const copy = { ...process.env }; + for (const key of Object.keys(copy)) { + if (copy[key] === undefined) delete copy[key]; + } + return copy; +} + +const printEnv = ['-e', 'process.stdout.write(JSON.stringify([Object.keys(process.env), process.env]))']; + +function check(output, label, expected = expectedEnv()) { + const [childKeys, childEnv] = JSON.parse(output); + assert.deepStrictEqual(childEnv, expected, `${label}: contents`); + assert.strictEqual(childEnv.TEST_DEFAULT_ENV_ADDED, 'added ünïcödé ✓'); + assert.strictEqual(childEnv.TEST_DEFAULT_ENV_EMPTY, ''); + assert.strictEqual(childEnv.TEST_DEFAULT_ENV_EQUALS, 'a=b=c'); + assert.ok(!('TEST_DEFAULT_ENV_DELETED' in childEnv)); + if (!common.isWindows) { + // Integer-like names are hoisted by object key ordering on both sides, so + // compare the order of the remaining names. + const nonIndex = (k) => !/^(?:0|[1-9]\d*)$/.test(k); + assert.deepStrictEqual(childKeys.filter(nonIndex), Object.keys(expected).filter(nonIndex), `${label}: order`); + } +} + +// spawnSync, options omitted entirely. +check(spawnSync(process.execPath, printEnv, { encoding: 'utf8' }).stdout, 'spawnSync no options'); +// Explicitly undefined / null env behave like the default. +check(spawnSync(process.execPath, printEnv, { encoding: 'utf8', env: undefined }).stdout, 'spawnSync env undefined'); +check(spawnSync(process.execPath, printEnv, { encoding: 'utf8', env: null }).stdout, 'spawnSync env null'); +// execFileSync goes through the same normalization. +check(execFileSync(process.execPath, printEnv, { encoding: 'utf8' }), 'execFileSync'); +// A user-supplied env is still passed through as given (not merged). +{ + const env = { ONLY: 'this', PATH: process.env.PATH }; + const out = spawnSync(process.execPath, printEnv, { encoding: 'utf8', env }).stdout; + const [, childEnv] = JSON.parse(out); + assert.strictEqual(childEnv.ONLY, 'this'); + assert.ok(!('TEST_DEFAULT_ENV_ADDED' in childEnv)); +} +// Async spawn (the environment is captured at spawn() time). +{ + const expectedAtSpawn = expectedEnv(); + const child = spawn(process.execPath, printEnv); + let out = ''; + child.stdout.setEncoding('utf8').on('data', (d) => { out += d; }); + child.on('close', common.mustCall((code) => { + assert.strictEqual(code, 0); + check(out, 'spawn', expectedAtSpawn); + })); +} +// A variable added after an earlier spawn is seen by a later one (no caching). +process.env.TEST_DEFAULT_ENV_LATE = 'late'; +{ + const [, childEnv] = JSON.parse(spawnSync(process.execPath, printEnv, { encoding: 'utf8' }).stdout); + assert.strictEqual(childEnv.TEST_DEFAULT_ENV_LATE, 'late'); +}