diff --git a/packages/util/http-proxy/src/install.ts b/packages/util/http-proxy/src/install.ts index 1b1bb79326..0fa3c85c24 100644 --- a/packages/util/http-proxy/src/install.ts +++ b/packages/util/http-proxy/src/install.ts @@ -76,28 +76,54 @@ export function proxyRouteFor(url: URL): ProxyRoute { * @returns a function restoring every name this call changed. */ function applyPolicyEnv(policy: ProxyPolicy): () => void { + const previousInherited = inheritedProxyEnv + inheritedProxyEnv = previousInherited ?? snapshotProxyEnv() + const published: Record = {} + for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { + const value = policy[field as keyof typeof POLICY_ENV_NAMES] + for (const name of names) published[name] = value + } + const restore = writeProxyEnv(published) + return () => { + restore() + inheritedProxyEnv = previousInherited + } +} + +/** + * Read every proxy name this package publishes, as `process.env` holds it now. + * + * @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name. + */ +function snapshotProxyEnv(): Record { + const snapshot: Record = {} + for (const names of Object.values(POLICY_ENV_NAMES)) { + for (const name of names) snapshot[name] = process.env[name] + } + return snapshot +} + +/** + * Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`. + * + * @param values - the value each name in {@link POLICY_ENV_NAMES} should hold. + * @returns a function restoring every name to what it held before this call. + */ +function writeProxyEnv(values: Readonly>): () => void { // Snapshot EVERY name before writing any of them. Windows folds environment names case-insensitively, // so reading the uppercase spelling after writing the lowercase one would read back the value just // written and restore the policy instead of the user's environment. - const previous = new Map() - for (const names of Object.values(POLICY_ENV_NAMES)) { - for (const name of names) previous.set(name, process.env[name]) - } - const previousInherited = inheritedProxyEnv - inheritedProxyEnv = previousInherited ?? Object.fromEntries(previous) - for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { - const value = policy[field as keyof typeof POLICY_ENV_NAMES] - for (const name of names) { - if (value === undefined || value === '') Reflect.deleteProperty(process.env, name) - else process.env[name] = value - } + const previous = snapshotProxyEnv() + for (const name of Object.keys(previous)) { + const value = values[name] + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value } return () => { - for (const [name, value] of previous) { + for (const [name, value] of Object.entries(previous)) { if (value === undefined) Reflect.deleteProperty(process.env, name) else process.env[name] = value } - inheritedProxyEnv = previousInherited } } @@ -159,6 +185,11 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise Promise { }) }) +describe('the environment while a direct policy is layered over a proxied one', () => { + it('hands a child the user\'s own values, and the outer normalization again afterwards', async () => { + await withCleanProxyEnv(async () => { + // The user exported one usable proxy and one this package refuses. + process.env.HTTP_PROXY = proxyUrl + process.env.https_proxy = 'socks5://127.0.0.1:1080' + const outer = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' })) + try { + // The outer install published its policy: the refused scheme is removed in both casings. + expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined]) + const off = await install(env({})) + try { + // A spawned child copies `process.env`, and `proxyEnvironmentForChild()` adds nothing under a + // direct policy — so what it copies has to be the user's own environment, not a normalization + // no active policy stands behind: the SOCKS value they set for `curl` is theirs again. + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toContain('socks5://127.0.0.1:1080') + expect(proxyEnvironmentForChild()).toEqual({}) + } finally { + await off.dispose() + } + // Ending the window re-applies what the outer install published. + expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined]) + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + } finally { + await outer.dispose() + } + }) + }) + + it('touches no environment when the install underneath proxied nothing', async () => { + process.env.HTTP_PROXY = 'http://untouched.example' + const outer = await install(env({})) + const inner = await install(env({})) + try { + expect(process.env.HTTP_PROXY).toBe('http://untouched.example') + } finally { + await inner.dispose() + await outer.dispose() + expect(process.env.HTTP_PROXY).toBe('http://untouched.example') + delete process.env.HTTP_PROXY + } + }) +}) + describe('the published environment', () => { it('restores every name from one snapshot taken before any write', async () => { process.env.http_proxy = 'http://before.example'