mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-03 21:58:58 -04:00
fix: prevent fork-bomb during packageManager-driven version switching (#11346)
* fix: prevent fork-bomb during packageManager-driven version switching When pnpm was installed via one method (e.g. `npm install -g pnpm@A`) and run in a project whose package.json's packageManager field selected a different pnpm version (pnpm@B), and a pnpm-workspace.yaml existed at the project root, the install-child spawned by `installPnpmToTools` to fetch pnpm@B inherited a cwd under the pnpm home directory. pnpm's workspace walk-up from there discovered the ancestor pnpm-workspace.yaml, adopted the root package.json, and re-triggered switchCliVersion inside the child. Because the target tool dir had not yet been symlinked in, the recursive installPnpmToTools call saw alreadyExisted === false and kicked off another nested install, recursing forever at 100% CPU. Force the install-child's environment to disable its own version handling: - `npm_config_manage_package_manager_versions=false` (v10 setting name) - `pnpm_config_pm_on_fail=ignore` (v11+ setting name) Also set the v11 setting on the final spawn at the end of switchCliVersion, so when v10 hands off to a v11 target the child's check/download paths stay disabled regardless of which env-var convention the child reads. Closes #11337. * test: add v11-switch and same-version regression tests for #11337 - v11 switch with a root pnpm-workspace.yaml: covers the primary #11337 reproducer (target major differs from running major). Before the fix this fork-bombed via the install-child's workspace walk-up; now it reaches the terminal spawn and `installPnpmToTools` completes. - Same-version short-circuit: with a root pnpm-workspace.yaml and `packageManager: pnpm@<current>`, `switchCliVersion` must return at the `pm.version === packageManager.version` guard, and the tool dir must not be created. Guards against a future regression where the ancestor pnpm-workspace.yaml alone accidentally triggers an install. * fix(installPnpmToTools): isolate the install-child from the caller's workspace Pass `--ignore-workspace` to the child pnpm so it doesn't walk up from the stage directory and adopt the caller's pnpm-workspace.yaml as its own root. That walk-up was both (a) the mechanism that caused the #11337 fork-bomb (the child would rediscover the caller's packageManager field and re-enter switchCliVersion) and (b) a correctness problem in its own right: once the child treats the caller's project as its workspace, `pnpm add` runs with semantics that don't match an isolated tool-dir install. The env-var guards from the previous commit stay in place as a defense-in-depth measure in case any future code path surfaces a wantedPackageManager without going through workspace discovery. Also fold the new v11-switch regression into the existing v11 test rather than adding a second v11 install, so CI doesn't fetch pnpm@11.0.0-rc.5 from the real npmjs registry twice. The tool-dir assertion in that test now doubles as a fork-bomb regression check for the v11-target path.
This commit is contained in:
13
.changeset/fix-switch-fork-bomb.md
Normal file
13
.changeset/fix-switch-fork-bomb.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@pnpm/exec.pnpm-cli-runner": minor
|
||||
"@pnpm/tools.plugin-commands-self-updater": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Fix an infinite fork-bomb that could happen when pnpm was installed with one version (e.g. `npm install -g pnpm@A`) and run inside a project whose `package.json` selected a different pnpm version via the `packageManager` field (e.g. `pnpm@B`), while a `pnpm-workspace.yaml` also existed at the project root.
|
||||
|
||||
The child process spawned by `installPnpmToTools` (to install the wanted pnpm version) inherited the parent's environment but had its working directory set to a fresh stage dir. pnpm's workspace walk-up from that stage dir would find the ancestor `pnpm-workspace.yaml` at the project root, adopt the root `package.json`, re-trigger `switchCliVersion`, and call `installPnpmToTools` again — recursively. Because the target tool dir isn't symlinked in until the outer install completes, each recursive call saw `alreadyExisted === false` and started another nested install, fork-bombing the process tree at ~100% CPU.
|
||||
|
||||
The child's environment is now forced to `manage-package-manager-versions=false` (v10) and `pm-on-fail=ignore` (v11+), which disables the package-manager-version handling in whichever pnpm runs as the child.
|
||||
|
||||
Fixes [#11337](https://github.com/pnpm/pnpm/issues/11337).
|
||||
@@ -1,10 +1,14 @@
|
||||
import path from 'path'
|
||||
import { sync as execSync } from 'execa'
|
||||
|
||||
export function runPnpmCli (command: string[], { cwd }: { cwd: string }): void {
|
||||
export function runPnpmCli (
|
||||
command: string[],
|
||||
{ cwd, env }: { cwd: string, env?: NodeJS.ProcessEnv }
|
||||
): void {
|
||||
const execOpts = {
|
||||
cwd,
|
||||
stdio: 'inherit' as const,
|
||||
...(env ? { env } : {}),
|
||||
}
|
||||
const execFileName = path.basename(process.execPath).toLowerCase()
|
||||
if (execFileName === 'pnpm' || execFileName === 'pnpm.exe') {
|
||||
|
||||
@@ -43,7 +43,12 @@ export async function switchCliVersion (config: Config): Promise<void> {
|
||||
env: {
|
||||
...process.env,
|
||||
[pnpmEnv.name]: pnpmEnv.value,
|
||||
// Disable the target pnpm's own package-manager-version management so
|
||||
// it doesn't try to switch again. We set both names because v10 reads
|
||||
// npm_config_* while v11+ reads pnpm_config_*, and this spawn may
|
||||
// target either major.
|
||||
npm_config_manage_package_manager_versions: 'false',
|
||||
pnpm_config_pm_on_fail: 'ignore',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { packageManager } from '@pnpm/cli-meta'
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
import { getToolDirPath } from '@pnpm/tools.path'
|
||||
import { sync as writeJsonFile } from 'write-json-file'
|
||||
@@ -88,7 +89,7 @@ test('commands that v10 passes through to npm keep passing through when packageM
|
||||
expect(stdout.toString()).toContain('Bump a package version')
|
||||
})
|
||||
|
||||
test('`pnpm version` routes through switchCliVersion to v11 when packageManager selects pnpm v11+', () => {
|
||||
test('`pnpm version` routes through switchCliVersion to v11 when packageManager selects pnpm v11+, even with a pnpm-workspace.yaml at the project root', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const version = '11.0.0-rc.5'
|
||||
@@ -99,6 +100,11 @@ test('`pnpm version` routes through switchCliVersion to v11 when packageManager
|
||||
PNPM_HOME: pnpmHome,
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
}
|
||||
// pnpm-workspace.yaml at the project root is what lets the install-child's
|
||||
// workspace walk-up see this dir's package.json + packageManager field.
|
||||
// Before the #11337 fix this would fork-bomb; the tool-dir assertion below
|
||||
// doubles as a fork-bomb regression check in the v11-target path.
|
||||
fs.writeFileSync('pnpm-workspace.yaml', '')
|
||||
writeJsonFile('package.json', {
|
||||
packageManager: `pnpm@${version}`,
|
||||
})
|
||||
@@ -139,6 +145,47 @@ test('npm passthrough still fires when packageManager selects pnpm v11+ but swit
|
||||
expect(stdout.toString()).toContain('Bump a package version')
|
||||
})
|
||||
|
||||
test('switching does not fork-bomb when a pnpm-workspace.yaml at the project root is visible to the install-child (#11337 regression)', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
// pnpm-workspace.yaml at the project root is what makes the install-child's
|
||||
// workspace walk-up hit this dir's package.json, pulling in its
|
||||
// packageManager field and re-triggering switchCliVersion inside the child.
|
||||
// Without the env-var guard in installPnpmToTools, that would recurse
|
||||
// indefinitely because the target tool dir is not symlinked in yet.
|
||||
fs.writeFileSync('pnpm-workspace.yaml', '')
|
||||
writeJsonFile('package.json', {
|
||||
packageManager: 'pnpm@9.3.0',
|
||||
})
|
||||
|
||||
const { stdout } = execPnpmSync(['help'], { env, timeout: 60_000 })
|
||||
|
||||
expect(stdout.toString()).toContain('Version 9.3.0')
|
||||
}, 90_000)
|
||||
|
||||
test('no spurious re-entry when the packageManager version matches the current pnpm, even with a pnpm-workspace.yaml at the root', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
const env = { PNPM_HOME: pnpmHome }
|
||||
// Same-version scenario: switchCliVersion must short-circuit at the
|
||||
// `pm.version === packageManager.version` check (switchCliVersion.ts). The
|
||||
// ancestor pnpm-workspace.yaml must not cause any detour through
|
||||
// installPnpmToTools. If it did, `pnpm -v` would either hang or print the
|
||||
// wrong version.
|
||||
fs.writeFileSync('pnpm-workspace.yaml', '')
|
||||
writeJsonFile('package.json', {
|
||||
packageManager: `pnpm@${packageManager.version}`,
|
||||
})
|
||||
|
||||
const { stdout } = execPnpmSync(['-v'], { env, timeout: 30_000 })
|
||||
|
||||
expect(stdout.toString().trim()).toBe(packageManager.version)
|
||||
// And the tool dir must not have been created — no install should have run.
|
||||
const toolDir = getToolDirPath({ pnpmHomeDir: pnpmHome, tool: { name: 'pnpm', version: packageManager.version } })
|
||||
expect(fs.existsSync(path.join(toolDir, 'bin/pnpm'))).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
test('throws error if pnpm tools dir is corrupt', () => {
|
||||
prepare()
|
||||
const pnpmHome = path.resolve('pnpm')
|
||||
|
||||
@@ -52,7 +52,27 @@ export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateC
|
||||
// which breaks the junctions on Windows.
|
||||
'--config.node-linker=hoisted',
|
||||
'--config.bin=bin',
|
||||
], { cwd: stage })
|
||||
// This is an isolated install into `stage` and must not inherit the
|
||||
// caller's workspace context. Without this, the child's workspace
|
||||
// walk-up from `stage` can discover an ancestor pnpm-workspace.yaml
|
||||
// and treat the caller's project as the workspace root — breaking the
|
||||
// add (it's outside the workspace's packages list) and, before the env
|
||||
// guards below existed, picking up the caller's packageManager field
|
||||
// and re-entering switchCliVersion for a fork bomb. See pnpm/pnpm#11337.
|
||||
'--ignore-workspace',
|
||||
], {
|
||||
cwd: stage,
|
||||
// Defense in depth against re-entering switchCliVersion in the child,
|
||||
// in case any future code path surfaces a wantedPackageManager without
|
||||
// going through workspace discovery. Both env-var names are set so the
|
||||
// guard works regardless of whether the child reads pnpm's v10
|
||||
// (npm_config_*) or v11+ (pnpm_config_*) convention.
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_manage_package_manager_versions: 'false',
|
||||
pnpm_config_pm_on_fail: 'ignore',
|
||||
},
|
||||
})
|
||||
if (currentPkgName === '@pnpm/exe') {
|
||||
linkExePlatformBinary(stage)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user