fix(self-update): trust only non-project config on pnpm 10 (#14169)
The pnpm 10 self-update handler passed the general resolved configuration to both the package resolver and the engine identity verifier. Workspace settings can preserve otherwise unknown runtime keys, so repository configuration could overwrite the internal package-manager registry and the verifier's test-only trusted-key override. Construct resolver and installer options from the dedicated package-manager bootstrap configuration, which is captured from non-project sources before workspace settings are merged. Move the shared bootstrap helper into `@pnpm/config` so automatic version switching and self-update use the same boundary. Pass unsigned-fixture keys through a separate explicit test argument instead of the runtime command options. Also skip automatic loading of the default project pnpmfile for self-update, while preserving its behavior for other commands. Add regressions for registry redirection, identity-verification bypass, trusted bootstrap config, and pnpmfile loading.
This commit is contained in:
1 parent
46bc7c9447
commit
e7888e5ea8
11 files changed
+144
-30
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@pnpm/cli-utils": patch
|
||||
"@pnpm/config": patch
|
||||
"@pnpm/tools.plugin-commands-self-updater": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
`pnpm self-update` now resolves and verifies pnpm through registry, authentication, proxy, and TLS settings from trusted non-project configuration. Project configuration and the default project pnpmfile can no longer redirect the pnpm download or disable engine identity verification.
|
||||
@@ -17,6 +17,7 @@ export async function getConfig (
|
||||
workspaceDir: string | undefined
|
||||
checkUnknownSetting?: boolean
|
||||
ignoreNonAuthSettingsFromLocal?: boolean
|
||||
forSelfUpdate?: boolean
|
||||
}
|
||||
): Promise<Config> {
|
||||
let { config, warnings } = await _getConfig({
|
||||
@@ -38,7 +39,7 @@ export async function getConfig (
|
||||
})
|
||||
}
|
||||
if (!config.ignorePnpmfile) {
|
||||
config.tryLoadDefaultPnpmfile = config.pnpmfile == null
|
||||
config.tryLoadDefaultPnpmfile = config.pnpmfile == null && !opts.forSelfUpdate
|
||||
const pnpmfiles = config.pnpmfile == null ? [] : Array.isArray(config.pnpmfile) ? config.pnpmfile : [config.pnpmfile]
|
||||
if (config.configDependencies) {
|
||||
const configModulesDir = path.join(config.lockfileDir ?? config.rootProjectManifestDir, 'node_modules/.pnpm-config')
|
||||
|
||||
@@ -82,3 +82,38 @@ test('allowBuilds does not add duplicates', async () => {
|
||||
expect(config.onlyBuiltDependencies).toEqual(['already-allowed'])
|
||||
expect(config.ignoredBuiltDependencies).toEqual(['already-blocked'])
|
||||
})
|
||||
|
||||
test('self-update does not load the default project pnpmfile', async () => {
|
||||
prepare()
|
||||
|
||||
fs.writeFileSync('.pnpmfile.cjs', `
|
||||
require('fs').writeFileSync('pnpmfile-was-loaded', '')
|
||||
module.exports = { hooks: {} }
|
||||
`)
|
||||
|
||||
await getConfig({}, {
|
||||
workspaceDir: process.cwd(),
|
||||
excludeReporter: false,
|
||||
rcOptionsTypes: {},
|
||||
forSelfUpdate: true,
|
||||
})
|
||||
|
||||
expect(fs.existsSync('pnpmfile-was-loaded')).toBe(false)
|
||||
})
|
||||
|
||||
test('the default project pnpmfile is loaded for other commands', async () => {
|
||||
prepare()
|
||||
|
||||
fs.writeFileSync('.pnpmfile.cjs', `
|
||||
require('fs').writeFileSync('pnpmfile-was-loaded', '')
|
||||
module.exports = { hooks: {} }
|
||||
`)
|
||||
|
||||
await getConfig({}, {
|
||||
workspaceDir: process.cwd(),
|
||||
excludeReporter: false,
|
||||
rcOptionsTypes: {},
|
||||
})
|
||||
|
||||
expect(fs.existsSync('pnpmfile-was-loaded')).toBe(true)
|
||||
})
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
export { types }
|
||||
|
||||
export { getOptionsFromRootManifest, getOptionsFromPnpmSettings, type OptionsFromRootManifest } from './getOptionsFromRootManifest.js'
|
||||
export {
|
||||
getPackageManagerBootstrapConfig,
|
||||
getPackageManagerRegistries,
|
||||
type PackageManagerBootstrapConfig,
|
||||
} from './packageManagerRegistries.js'
|
||||
export * from './readLocalConfig.js'
|
||||
export { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency } from './concurrency.js'
|
||||
|
||||
|
||||
+15
-10
@@ -1,9 +1,12 @@
|
||||
import { type Config, type PackageManagerNetworkConfig } from '@pnpm/config'
|
||||
import { type Registries } from '@pnpm/types'
|
||||
|
||||
import { type Config, type PackageManagerNetworkConfig } from './Config.js'
|
||||
|
||||
const DEFAULT_PACKAGE_MANAGER_REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
export type PackageManagerBootstrapConfig = Partial<PackageManagerNetworkConfig> & {
|
||||
type PackageManagerConfig = Pick<Config, 'packageManagerNetworkConfig' | 'packageManagerRegistries'>
|
||||
|
||||
export type PackageManagerBootstrapConfig = PackageManagerNetworkConfig & {
|
||||
registries: Registries
|
||||
}
|
||||
|
||||
@@ -13,16 +16,18 @@ export type PackageManagerBootstrapConfig = Partial<PackageManagerNetworkConfig>
|
||||
* and global .npmrc), defaulting to the public npm registry — repository
|
||||
* config must not steer where pnpm fetches the binary it is about to execute.
|
||||
*/
|
||||
export function getPackageManagerRegistries (config: Config): Registries {
|
||||
export function getPackageManagerBootstrapConfig (config: PackageManagerConfig): PackageManagerBootstrapConfig {
|
||||
return {
|
||||
rawConfig: {},
|
||||
sslConfigs: {},
|
||||
...config.packageManagerNetworkConfig,
|
||||
registries: getPackageManagerRegistries(config),
|
||||
}
|
||||
}
|
||||
|
||||
export function getPackageManagerRegistries (config: PackageManagerConfig): Registries {
|
||||
return {
|
||||
default: DEFAULT_PACKAGE_MANAGER_REGISTRY,
|
||||
...config.packageManagerRegistries,
|
||||
}
|
||||
}
|
||||
|
||||
export function getPackageManagerBootstrapConfig (config: Config): PackageManagerBootstrapConfig {
|
||||
return {
|
||||
...config.packageManagerNetworkConfig,
|
||||
registries: getPackageManagerRegistries(config),
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import PATH from 'path-name'
|
||||
import { getConfig, getPackageManagerBootstrapConfig } from '@pnpm/config'
|
||||
import { getCurrentBranch } from '@pnpm/git-utils'
|
||||
import { getConfig } from '@pnpm/config'
|
||||
import loadNpmConf from '@pnpm/npm-conf'
|
||||
import { prepare, prepareEmpty } from '@pnpm/prepare'
|
||||
import { fixtures } from '@pnpm/test-fixtures'
|
||||
@@ -528,6 +528,11 @@ test('packageManagerRegistries is built from trusted config only', async () => {
|
||||
expect(config.packageManagerRegistries?.default).toBe('https://user-registry.example.test/')
|
||||
expect(config.packageManagerRegistries?.['@scope']).toBeUndefined()
|
||||
expect(config.packageManagerNetworkConfig?.rawConfig.registry).toBe('https://user-registry.example.test/')
|
||||
|
||||
const bootstrapConfig = getPackageManagerBootstrapConfig(config)
|
||||
expect(bootstrapConfig.registries.default).toBe('https://user-registry.example.test/')
|
||||
expect(bootstrapConfig.registries['@scope']).toBeUndefined()
|
||||
expect(bootstrapConfig.rawConfig?.registry).toBe('https://user-registry.example.test/')
|
||||
})
|
||||
|
||||
test('packageManagerRegistries defaults to the public npm registry', async () => {
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
workspaceDir,
|
||||
checkUnknownSetting: false,
|
||||
ignoreNonAuthSettingsFromLocal: isDlxOrCreateCommand,
|
||||
forSelfUpdate: cmd === 'self-update',
|
||||
}) as typeof config
|
||||
if (!isExecutedByCorepack() && cmd !== 'setup' && config.wantedPackageManager != null) {
|
||||
if (config.managePackageManagerVersions && config.wantedPackageManager?.name === 'pnpm' && cmd !== 'self-update') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { type Config } from '@pnpm/config'
|
||||
import { type Config, getPackageManagerBootstrapConfig } from '@pnpm/config'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { globalWarn } from '@pnpm/logger'
|
||||
import { packageManager } from '@pnpm/cli-meta'
|
||||
@@ -8,8 +8,6 @@ import { installPnpmToTools } from '@pnpm/tools.plugin-commands-self-updater'
|
||||
import spawn from 'cross-spawn'
|
||||
import semver from 'semver'
|
||||
|
||||
import { getPackageManagerBootstrapConfig } from './packageManagerRegistries.js'
|
||||
|
||||
export async function switchCliVersion (config: Config): Promise<void> {
|
||||
const pm = config.wantedPackageManager
|
||||
if (pm == null || pm.name !== 'pnpm' || pm.version == null || pm.version === packageManager.version) return
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fastPathTemp as pathTemp } from 'path-temp'
|
||||
import semver from 'semver'
|
||||
import symlinkDir from 'symlink-dir'
|
||||
import { type SelfUpdateCommandOptions } from './selfUpdate.js'
|
||||
import { verifyPnpmEngineIdentity } from './verifyPnpmEngineIdentity.js'
|
||||
import { type RegistryKey, verifyPnpmEngineIdentity } from './verifyPnpmEngineIdentity.js'
|
||||
|
||||
export interface InstallPnpmToToolsResult {
|
||||
binDir: string
|
||||
@@ -17,7 +17,11 @@ export interface InstallPnpmToToolsResult {
|
||||
alreadyExisted: boolean
|
||||
}
|
||||
|
||||
export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateCommandOptions): Promise<InstallPnpmToToolsResult> {
|
||||
export async function installPnpmToTools (
|
||||
pnpmVersion: string,
|
||||
opts: SelfUpdateCommandOptions,
|
||||
trustedKeys?: RegistryKey[]
|
||||
): Promise<InstallPnpmToToolsResult> {
|
||||
const currentPkgName = getCurrentPackageName()
|
||||
const targetPkgName = pnpmPackageNameToInstall(pnpmVersion, currentPkgName)
|
||||
// The v11-only darwin-x64 fallback (see pnpmPackageNameToInstall) swaps the
|
||||
@@ -93,7 +97,7 @@ export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateC
|
||||
// from the wrapper's own (still untrusted) package.json, so it must not run
|
||||
// until the wrapper is proven to be a genuine, signed pnpm release. On
|
||||
// failure the stage is removed by the catch below.
|
||||
await verifyPnpmEngineIdentity(stage, targetPkgName, pnpmVersion, opts)
|
||||
await verifyPnpmEngineIdentity(stage, targetPkgName, pnpmVersion, { ...opts, trustedKeys })
|
||||
// pnpm's own installs run with --ignore-scripts, so the wrapper's
|
||||
// preinstall (which links the native binary over the placeholder bin) never
|
||||
// runs — replicate it here. Needed for any wrapper that ships a native
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path'
|
||||
import { docsUrl } from '@pnpm/cli-utils'
|
||||
import { packageManager, isExecutedByCorepack, standaloneInstallCommand } from '@pnpm/cli-meta'
|
||||
import { createResolver } from '@pnpm/client'
|
||||
import { type Config, types as allTypes } from '@pnpm/config'
|
||||
import { type Config, getPackageManagerBootstrapConfig, types as allTypes } from '@pnpm/config'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { globalWarn } from '@pnpm/logger'
|
||||
import { readProjectManifest } from '@pnpm/read-project-manifest'
|
||||
@@ -54,26 +54,35 @@ export type SelfUpdateCommandOptions = Pick<Config,
|
||||
| 'lockfileDir'
|
||||
| 'managePackageManagerVersions'
|
||||
| 'modulesDir'
|
||||
| 'packageManagerNetworkConfig'
|
||||
| 'packageManagerRegistries'
|
||||
| 'pnpmHomeDir'
|
||||
| 'rawConfig'
|
||||
| 'registries'
|
||||
| 'rootProjectManifestDir'
|
||||
| 'wantedPackageManager'
|
||||
> & {
|
||||
/** See {@link VerifyPnpmEngineIdentityOptions.trustedKeys} — a test seam. */
|
||||
trustedKeys?: VerifyPnpmEngineIdentityOptions['trustedKeys']
|
||||
>
|
||||
|
||||
interface SelfUpdateHandlerTestOptions {
|
||||
trustedKeys: VerifyPnpmEngineIdentityOptions['trustedKeys']
|
||||
}
|
||||
|
||||
export async function handler (
|
||||
opts: SelfUpdateCommandOptions,
|
||||
params: string[]
|
||||
params: string[],
|
||||
testOptions?: SelfUpdateHandlerTestOptions
|
||||
): Promise<undefined | string> {
|
||||
if (isExecutedByCorepack()) {
|
||||
throw new PnpmError('CANT_SELF_UPDATE_IN_COREPACK', 'pnpm cannot update itself when it is executed by Corepack', {
|
||||
hint: `Install pnpm with the standalone script instead: ${standaloneInstallCommand()}`,
|
||||
})
|
||||
}
|
||||
const { resolve } = createResolver({ ...opts, authConfig: opts.rawConfig })
|
||||
const bootstrapConfig = getPackageManagerBootstrapConfig(opts)
|
||||
const { resolve } = createResolver({
|
||||
...opts,
|
||||
...bootstrapConfig,
|
||||
authConfig: bootstrapConfig.rawConfig,
|
||||
})
|
||||
const pkgName = 'pnpm'
|
||||
// `pnpm self-update` (no args) defaults to the `latest` dist-tag, but we
|
||||
// refuse to downgrade in that case — `latest` on the registry can lag the
|
||||
@@ -136,7 +145,10 @@ export async function handler (
|
||||
return `The currently active ${packageManager.name} v${packageManager.version} is newer than the "latest" version on the registry (v${resolution.manifest.version}). No update performed. Run "pnpm self-update latest" to downgrade.`
|
||||
}
|
||||
|
||||
const { baseDir, alreadyExisted } = await installPnpmToTools(resolution.manifest.version, opts)
|
||||
const { baseDir, alreadyExisted } = await installPnpmToTools(resolution.manifest.version, {
|
||||
...opts,
|
||||
...bootstrapConfig,
|
||||
}, testOptions?.trustedKeys)
|
||||
await linkBins(path.join(baseDir, 'node_modules'), opts.pnpmHomeDir,
|
||||
{
|
||||
warn: globalWarn,
|
||||
|
||||
@@ -61,9 +61,6 @@ function prepareOptions (dir: string) {
|
||||
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
|
||||
dir,
|
||||
managePackageManagerVersions: false,
|
||||
// The fixture pnpm installed here is not signed with npm's real keys, so
|
||||
// skip the engine identity signature check (empty trusted-keys = skip).
|
||||
trustedKeys: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +94,7 @@ test('self-update', async () => {
|
||||
.get('/pnpm/-/pnpm-9.1.0.tgz')
|
||||
.replyWithFile(200, pnpmTarballPath)
|
||||
|
||||
await selfUpdate.handler(opts, [])
|
||||
await selfUpdate.handler(opts, [], { trustedKeys: [] })
|
||||
|
||||
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(opts.pnpmHomeDir, '.tools/pnpm/9.1.0/node_modules/pnpm/package.json'), 'utf8'))
|
||||
expect(pnpmPkgJson.version).toBe('9.1.0')
|
||||
@@ -113,6 +110,49 @@ test('self-update', async () => {
|
||||
expect(stdout.toString().trim()).toBe('9.1.0')
|
||||
})
|
||||
|
||||
test('project config cannot redirect the download', async () => {
|
||||
const opts = prepare()
|
||||
const trustedRegistry = 'https://trusted.example.test/'
|
||||
const projectRegistry = 'https://project.example.test/'
|
||||
nock(trustedRegistry)
|
||||
.get('/pnpm')
|
||||
.reply(200, createMetadata('9.0.0', trustedRegistry))
|
||||
const projectRegistryMock = nock(projectRegistry)
|
||||
.get('/pnpm')
|
||||
.reply(200, createMetadata('9.1.0', projectRegistry))
|
||||
|
||||
const projectConfig = {
|
||||
...opts,
|
||||
packageManagerNetworkConfig: { rawConfig: {}, sslConfigs: {} },
|
||||
packageManagerRegistries: { default: trustedRegistry },
|
||||
rawConfig: { registry: projectRegistry },
|
||||
registries: { default: projectRegistry },
|
||||
}
|
||||
|
||||
await expect(selfUpdate.handler(projectConfig, [])).resolves.toBe('The currently active pnpm v9.0.0 is already "latest" and doesn\'t need an update')
|
||||
expect(projectRegistryMock.isDone()).toBe(false)
|
||||
})
|
||||
|
||||
test('project config cannot disable identity verification', async () => {
|
||||
const opts = prepare()
|
||||
nock(opts.registries.default)
|
||||
.get('/pnpm')
|
||||
.twice()
|
||||
.reply(200, createMetadata('9.1.0', opts.registries.default))
|
||||
nock(opts.registries.default)
|
||||
.get('/pnpm/-/pnpm-9.1.0.tgz')
|
||||
.replyWithFile(200, pnpmTarballPath)
|
||||
|
||||
const projectConfig = {
|
||||
...opts,
|
||||
trustedKeys: [],
|
||||
} as selfUpdate.SelfUpdateCommandOptions & { trustedKeys: never[] }
|
||||
|
||||
await expect(selfUpdate.handler(projectConfig, [])).rejects.toMatchObject({
|
||||
code: 'ERR_PNPM_PNPM_ENGINE_IDENTITY_MISMATCH',
|
||||
})
|
||||
})
|
||||
|
||||
test('self-update by exact version', async () => {
|
||||
const opts = prepare()
|
||||
nock(opts.registries.default)
|
||||
@@ -122,7 +162,7 @@ test('self-update by exact version', async () => {
|
||||
.get('/pnpm/-/pnpm-9.1.0.tgz')
|
||||
.replyWithFile(200, pnpmTarballPath)
|
||||
|
||||
await selfUpdate.handler(opts, ['9.1.0'])
|
||||
await selfUpdate.handler(opts, ['9.1.0'], { trustedKeys: [] })
|
||||
|
||||
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(opts.pnpmHomeDir, '.tools/pnpm/9.1.0/node_modules/pnpm/package.json'), 'utf8'))
|
||||
expect(pnpmPkgJson.version).toBe('9.1.0')
|
||||
|
||||
Reference in new issue
Block a user