diff --git a/.changeset/calm-lions-trust.md b/.changeset/calm-lions-trust.md new file mode 100644 index 0000000000..453d1c7978 --- /dev/null +++ b/.changeset/calm-lions-trust.md @@ -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. diff --git a/cli/cli-utils/src/getConfig.ts b/cli/cli-utils/src/getConfig.ts index e2d0d661a7..9ab62de2d2 100644 --- a/cli/cli-utils/src/getConfig.ts +++ b/cli/cli-utils/src/getConfig.ts @@ -17,6 +17,7 @@ export async function getConfig ( workspaceDir: string | undefined checkUnknownSetting?: boolean ignoreNonAuthSettingsFromLocal?: boolean + forSelfUpdate?: boolean } ): Promise { 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') diff --git a/cli/cli-utils/test/getConfig.test.ts b/cli/cli-utils/test/getConfig.test.ts index 148f8de1bc..2c378833cd 100644 --- a/cli/cli-utils/test/getConfig.test.ts +++ b/cli/cli-utils/test/getConfig.test.ts @@ -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) +}) diff --git a/config/config/src/index.ts b/config/config/src/index.ts index e2dc0a20c4..2df7536f3d 100644 --- a/config/config/src/index.ts +++ b/config/config/src/index.ts @@ -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' diff --git a/pnpm/src/packageManagerRegistries.ts b/config/config/src/packageManagerRegistries.ts similarity index 57% rename from pnpm/src/packageManagerRegistries.ts rename to config/config/src/packageManagerRegistries.ts index 3f69c93641..3c0811c255 100644 --- a/pnpm/src/packageManagerRegistries.ts +++ b/config/config/src/packageManagerRegistries.ts @@ -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 & { +type PackageManagerConfig = Pick + +export type PackageManagerBootstrapConfig = PackageManagerNetworkConfig & { registries: Registries } @@ -13,16 +16,18 @@ export type PackageManagerBootstrapConfig = Partial * 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), - } -} diff --git a/config/config/test/index.ts b/config/config/test/index.ts index 1dfa17611e..e7eb403b50 100644 --- a/config/config/test/index.ts +++ b/config/config/test/index.ts @@ -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 () => { diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index 9bf9ff3aa1..20da2e77b7 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -115,6 +115,7 @@ export async function main (inputArgv: string[]): Promise { 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') { diff --git a/pnpm/src/switchCliVersion.ts b/pnpm/src/switchCliVersion.ts index 25a348a16c..c75d399af3 100644 --- a/pnpm/src/switchCliVersion.ts +++ b/pnpm/src/switchCliVersion.ts @@ -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 { const pm = config.wantedPackageManager if (pm == null || pm.name !== 'pnpm' || pm.version == null || pm.version === packageManager.version) return diff --git a/tools/plugin-commands-self-updater/src/installPnpmToTools.ts b/tools/plugin-commands-self-updater/src/installPnpmToTools.ts index b192caaa19..748e525394 100644 --- a/tools/plugin-commands-self-updater/src/installPnpmToTools.ts +++ b/tools/plugin-commands-self-updater/src/installPnpmToTools.ts @@ -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 { +export async function installPnpmToTools ( + pnpmVersion: string, + opts: SelfUpdateCommandOptions, + trustedKeys?: RegistryKey[] +): Promise { 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 diff --git a/tools/plugin-commands-self-updater/src/selfUpdate.ts b/tools/plugin-commands-self-updater/src/selfUpdate.ts index c2e0689eb1..0b68b92a52 100644 --- a/tools/plugin-commands-self-updater/src/selfUpdate.ts +++ b/tools/plugin-commands-self-updater/src/selfUpdate.ts @@ -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 & { - /** 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 { 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, diff --git a/tools/plugin-commands-self-updater/test/selfUpdate.test.ts b/tools/plugin-commands-self-updater/test/selfUpdate.test.ts index 5e03ac9cf7..af345073e4 100644 --- a/tools/plugin-commands-self-updater/test/selfUpdate.test.ts +++ b/tools/plugin-commands-self-updater/test/selfUpdate.test.ts @@ -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')