diff --git a/.changeset/clean-package-manager-registries.md b/.changeset/clean-package-manager-registries.md new file mode 100644 index 0000000000..81bc9a09f8 --- /dev/null +++ b/.changeset/clean-package-manager-registries.md @@ -0,0 +1,6 @@ +--- +"@pnpm/config.reader": patch +"pnpm": patch +--- + +Resolve package-manager bootstrap dependencies with trusted user or CLI registry and network config, and reject package-manager env-lockfile records that do not use registry package paths with integrity-only resolutions before auto-switch execution. diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index 2b7397ffb9..cff15ac419 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -18,6 +18,18 @@ export type UniversalOptions = Pick export type VerifyDepsBeforeRun = 'install' | 'warn' | 'error' | 'prompt' | false +export interface PackageManagerNetworkConfig { + ca?: string | string[] + cert?: string | string[] + configByUri: Record + httpProxy?: string + httpsProxy?: string + key?: string + localAddress?: string + noProxy?: string | boolean + strictSsl?: boolean +} + /** * Runtime state, workspace context, and CLI metadata. * These fields are NOT user-facing settings — they are computed at startup @@ -226,6 +238,8 @@ export interface Config extends OptionsFromRootManifest { pnprServer?: string registries: Registries + packageManagerRegistries?: Registries + packageManagerNetworkConfig?: PackageManagerNetworkConfig namedRegistries?: Record configByUri: Record ignoreWorkspaceRootCheck: boolean diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index 187626e82c..d47a8c026a 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -29,6 +29,7 @@ import type { Config, ConfigContext, ConfigWithDeprecatedSettings, + PackageManagerNetworkConfig, ProjectConfig, UniversalOptions, VerifyDepsBeforeRun, @@ -322,10 +323,21 @@ export async function getConfig (opts: { default: normalizeRegistryUrl(pnpmConfig.authConfig.registry), ...networkConfigs.registries, } + const trustedAuthConfig = pickIniConfig(npmrcResult.trustedConfig) + const trustedNetworkConfigs = getNetworkConfigs(trustedAuthConfig) pnpmConfig.registries = { ...registriesFromNpmrc } if (explicitlySetKeys.has('registry') && typeof pnpmConfig.registry === 'string') { pnpmConfig.registries.default = normalizeRegistryUrl(pnpmConfig.registry) } + pnpmConfig.packageManagerRegistries = { + default: normalizeRegistryUrl(trustedAuthConfig.registry as string), + ...trustedNetworkConfigs.registries, + } + pnpmConfig.packageManagerNetworkConfig = createPackageManagerNetworkConfig( + npmrcResult.trustedConfig, + trustedNetworkConfigs.configByUri ?? {}, + env + ) pnpmConfig.configByUri = { ...networkConfigs.configByUri } // tokenHelper must only come from user-level config (~/.npmrc or global auth.ini), @@ -496,6 +508,7 @@ export async function getConfig (opts: { throw new TypeError(`Unexpected type of registry, expecting a string but received ${JSON.stringify(value)}`) } pnpmConfig.registries.default = normalizeRegistryUrl(value) + pnpmConfig.packageManagerRegistries.default = normalizeRegistryUrl(value) } } @@ -716,6 +729,42 @@ function getProcessEnv (env: string): string | undefined { process.env[env.toLowerCase()] } +function createPackageManagerNetworkConfig ( + trustedConfig: Record, + configByUri: PackageManagerNetworkConfig['configByUri'], + env: Record +): PackageManagerNetworkConfig { + const httpsProxy = getProxyValue( + trustedConfig['https-proxy'] ?? trustedConfig.proxy, + getEnvValue(env, 'https_proxy') + ) + const httpProxy = getProxyValue( + trustedConfig['http-proxy'], + httpsProxy ?? getEnvValue(env, 'http_proxy') ?? getEnvValue(env, 'proxy') + ) + return { + ca: trustedConfig.ca as string | string[] | undefined, + cert: trustedConfig.cert as string | string[] | undefined, + configByUri, + httpProxy, + httpsProxy, + key: trustedConfig.key as string | undefined, + localAddress: trustedConfig['local-address'] as string | undefined, + noProxy: (trustedConfig['no-proxy'] ?? trustedConfig.noproxy ?? getEnvValue(env, 'no_proxy')) as string | boolean | undefined, + strictSsl: trustedConfig['strict-ssl'] as boolean | undefined, + } +} + +function getEnvValue (env: Record, key: string): string | undefined { + return env[key] ?? env[key.toUpperCase()] ?? env[key.toLowerCase()] +} + +function getProxyValue (value: unknown, fallback: string | undefined): string | undefined { + if (value === false || value === null) return undefined + if (typeof value === 'string' && value.length > 0) return value + return fallback +} + // Look up a `pnpm_config_` env var, accepting both lowercase and // uppercase forms. Used for env vars that need to be read before the // general parseEnvVars pass, such as those that affect which .npmrc file diff --git a/config/reader/src/loadNpmrcFiles.ts b/config/reader/src/loadNpmrcFiles.ts index fe7abe319a..d5a36f8552 100644 --- a/config/reader/src/loadNpmrcFiles.ts +++ b/config/reader/src/loadNpmrcFiles.ts @@ -18,6 +18,8 @@ export interface NpmrcConfigResult { mergedConfig: Record /** Raw config suitable for pnpmConfig.authConfig (filtered through pickIniConfig by consumer) */ rawConfig: Record + /** Non-project npmrc config used for package-manager bootstrap */ + trustedConfig: Record /** Workspace .npmrc data */ workspaceNpmrc: Record /** User ~/.npmrc data (for token helpers) */ @@ -115,6 +117,15 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult { } } + const trustedConfig: Record = {} + for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, cliOptions]) { + for (const [key, value] of Object.entries(source)) { + if (isNpmrcReadableKey(key)) { + trustedConfig[key] = value + } + } + } + // Build rawConfig with same priority order const rawConfig = { ...pnpmBuiltinConfig, @@ -128,6 +139,7 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult { return { mergedConfig, rawConfig, + trustedConfig, workspaceNpmrc, userConfig, localPrefix, diff --git a/config/reader/test/index.ts b/config/reader/test/index.ts index a4b2bc89c6..4807f59e60 100644 --- a/config/reader/test/index.ts +++ b/config/reader/test/index.ts @@ -605,6 +605,7 @@ test('registries in current directory\'s .npmrc have bigger priority then global '@bar': 'https://bar.com/', '@qar': 'https://qar.com/qar', }) + expect(config.packageManagerRegistries?.default).toBe('https://default.com/') }) test('project .npmrc does not expand env variables in registry URLs', async () => { @@ -818,6 +819,69 @@ test('pnpm-workspace.yaml request destinations do not expand env variables', asy expect(JSON.stringify(config)).not.toContain('secret') }) +test('package manager bootstrap registries ignore project workspace registries', async () => { + prepareEmpty() + + fs.writeFileSync('user.npmrc', [ + 'registry=https://trusted.example.com/', + '@pnpm:registry=https://trusted-pnpm.example.com/', + 'strict-ssl=true', + '//trusted.example.com/:_authToken=trusted-token', + '', + ].join('\n'), 'utf8') + fs.writeFileSync('.npmrc', [ + 'registry=https://project.example.com/', + 'https-proxy=http://project-proxy.example.com:8080', + 'strict-ssl=false', + '//project.example.com/:_authToken=project-token', + '', + ].join('\n'), 'utf8') + writeYamlFileSync('pnpm-workspace.yaml', { + registries: { + '@pnpm': 'https://workspace-pnpm.example.com/', + default: 'https://workspace.example.com/', + }, + }) + + const { config } = await getConfig({ + cliOptions: { + userconfig: path.resolve('user.npmrc'), + }, + env: { + ...env, + XDG_CONFIG_HOME: path.resolve('xdg-config'), + https_proxy: 'http://trusted-env-proxy.example.com:8080', + no_proxy: 'trusted-env-no-proxy.example.com', + }, + packageManager: { name: 'pnpm', version: '1.0.0' }, + workspaceDir: process.cwd(), + }) + + expect(config.registries).toMatchObject({ + '@pnpm': 'https://workspace-pnpm.example.com/', + default: 'https://workspace.example.com/', + }) + expect(config.packageManagerRegistries).toMatchObject({ + '@pnpm': 'https://trusted-pnpm.example.com/', + default: 'https://trusted.example.com/', + }) + expect(config.httpsProxy).toBe('http://project-proxy.example.com:8080') + expect(config.strictSsl).toBe(false) + expect(config.configByUri).toMatchObject({ + '//project.example.com/': { creds: { authToken: 'project-token' } }, + }) + expect(config.packageManagerNetworkConfig).toMatchObject({ + configByUri: { + '//trusted.example.com/': { creds: { authToken: 'trusted-token' } }, + }, + httpProxy: 'http://trusted-env-proxy.example.com:8080', + httpsProxy: 'http://trusted-env-proxy.example.com:8080', + noProxy: 'trusted-env-no-proxy.example.com', + strictSsl: true, + }) + expect(config.packageManagerNetworkConfig?.configByUri['//project.example.com/']).toBeUndefined() +}) + test('CLI --registry overrides pnpm-workspace.yaml registries.default (#10099)', async () => { prepareEmpty() @@ -834,6 +898,7 @@ test('CLI --registry overrides pnpm-workspace.yaml registries.default (#10099)', }) expect(config.registry).toBe('https://cli.example.com/') + expect(config.packageManagerRegistries?.default).toBe('https://cli.example.com/') }) test('auth tokens from pnpm auth file override ~/.npmrc', async () => { diff --git a/pnpm/src/packageManagerLockfile.ts b/pnpm/src/packageManagerLockfile.ts new file mode 100644 index 0000000000..92f4e0bef6 --- /dev/null +++ b/pnpm/src/packageManagerLockfile.ts @@ -0,0 +1,88 @@ +import { parse as parseDepPath, refToRelative, removeSuffix } from '@pnpm/deps.path' +import { PnpmError } from '@pnpm/error' +import type { EnvLockfile, LockfilePackageInfo, LockfileResolution } from '@pnpm/lockfile.types' +import type { DepPath } from '@pnpm/types' + +export function assertPackageManagerLockfileUsesRegistryResolutions (envLockfile: EnvLockfile): void { + const packageManagerDependencies = envLockfile.importers['.'].packageManagerDependencies + if (packageManagerDependencies == null) { + throw new PnpmError('INVALID_PACKAGE_MANAGER_LOCKFILE', 'The packageManager dependencies were not found in pnpm-lock.yaml') + } + + const visited = new Set() + for (const [name, dependency] of Object.entries(packageManagerDependencies)) { + const depPath = refToRelative(dependency.version, name) + if (depPath == null) { + throw invalidPackageManagerLockfile(name) + } + assertRegistryPackageManagerDependency(envLockfile, depPath, visited) + } +} + +function assertRegistryPackageManagerDependency ( + envLockfile: EnvLockfile, + depPath: DepPath, + visited: Set +): void { + if (visited.has(depPath)) return + visited.add(depPath) + + const packageInfo = envLockfile.packages[removeSuffix(depPath)] + const snapshot = envLockfile.snapshots[depPath] + if (packageInfo == null || snapshot == null) { + throw invalidPackageManagerLockfile(depPath) + } + + assertRegistryPackagePath(depPath, packageInfo) + assertIntegrityOnlyResolution(depPath, packageInfo.resolution) + + for (const [name, ref] of Object.entries({ + ...snapshot.dependencies, + ...snapshot.optionalDependencies, + })) { + const nextDepPath = refToRelative(ref, name) + if (nextDepPath == null) { + throw invalidPackageManagerLockfile(depPath) + } + assertRegistryPackageManagerDependency(envLockfile, nextDepPath, visited) + } +} + +function assertRegistryPackagePath (depPath: DepPath, packageInfo: LockfilePackageInfo): void { + const parsedDepPath = parseDepPath(depPath) + if (parsedDepPath.name == null || parsedDepPath.version == null || parsedDepPath.nonSemverVersion != null) { + throw invalidPackageManagerLockfile(depPath) + } + if (packageInfo.id != null) { + throw invalidPackageManagerLockfile(depPath) + } + if (packageInfo.name != null && packageInfo.name !== parsedDepPath.name) { + throw invalidPackageManagerLockfile(depPath) + } + if (packageInfo.version != null && packageInfo.version !== parsedDepPath.version) { + throw invalidPackageManagerLockfile(depPath) + } +} + +function assertIntegrityOnlyResolution (depPath: DepPath, resolution: LockfileResolution): void { + if (resolution == null || typeof resolution !== 'object' || Array.isArray(resolution)) { + throw invalidPackageManagerLockfile(depPath) + } + const resolutionKeys = Object.keys(resolution) + if ( + resolutionKeys.length !== 1 || + resolutionKeys[0] !== 'integrity' || + !('integrity' in resolution) || + typeof resolution.integrity !== 'string' || + resolution.integrity.length === 0 + ) { + throw invalidPackageManagerLockfile(depPath) + } +} + +function invalidPackageManagerLockfile (depPath: string): PnpmError { + return new PnpmError( + 'INVALID_PACKAGE_MANAGER_LOCKFILE', + `The packageManager dependency "${depPath}" in pnpm-lock.yaml must use a registry package path and an integrity-only resolution` + ) +} diff --git a/pnpm/src/packageManagerRegistries.ts b/pnpm/src/packageManagerRegistries.ts new file mode 100644 index 0000000000..367b121f37 --- /dev/null +++ b/pnpm/src/packageManagerRegistries.ts @@ -0,0 +1,39 @@ +import type { Config } from '@pnpm/config.reader' +import type { Registries, RegistryConfig } from '@pnpm/types' + +const DEFAULT_PACKAGE_MANAGER_REGISTRY = 'https://registry.npmjs.org/' + +export interface PackageManagerBootstrapConfig { + ca?: string | string[] + cert?: string | string[] + configByUri: Record + httpProxy?: string + httpsProxy?: string + key?: string + localAddress?: string + noProxy?: string | boolean + registries: Registries + strictSsl?: boolean +} + +export function getPackageManagerRegistries (config: Config): Registries { + return { + default: DEFAULT_PACKAGE_MANAGER_REGISTRY, + ...config.packageManagerRegistries, + } +} + +export function getPackageManagerBootstrapConfig (config: Config): PackageManagerBootstrapConfig { + return { + ca: config.packageManagerNetworkConfig?.ca, + cert: config.packageManagerNetworkConfig?.cert, + configByUri: config.packageManagerNetworkConfig?.configByUri ?? {}, + httpProxy: config.packageManagerNetworkConfig?.httpProxy, + httpsProxy: config.packageManagerNetworkConfig?.httpsProxy, + key: config.packageManagerNetworkConfig?.key, + localAddress: config.packageManagerNetworkConfig?.localAddress, + noProxy: config.packageManagerNetworkConfig?.noProxy, + registries: getPackageManagerRegistries(config), + strictSsl: config.packageManagerNetworkConfig?.strictSsl, + } +} diff --git a/pnpm/src/switchCliVersion.test.ts b/pnpm/src/switchCliVersion.test.ts new file mode 100644 index 0000000000..7e6749f315 --- /dev/null +++ b/pnpm/src/switchCliVersion.test.ts @@ -0,0 +1,378 @@ +import { beforeEach, expect, jest, test } from '@jest/globals' +import type { Config, ConfigContext } from '@pnpm/config.reader' +import type { EnvLockfile } from '@pnpm/lockfile.types' + +const closeStore = jest.fn<() => Promise>(async () => {}) +const createStoreController = jest.fn<(opts: object) => Promise<{ + ctrl: { close: typeof closeStore } + dir: string +}>>(async () => ({ + ctrl: { close: closeStore }, + dir: '/store', +})) +const envLockfile: EnvLockfile = { + importers: { + '.': { + configDependencies: {}, + packageManagerDependencies: { + '@pnpm/exe': { specifier: '9.3.0', version: '9.3.0' }, + pnpm: { specifier: '9.3.0', version: '9.3.0' }, + }, + }, + }, + lockfileVersion: '9.0', + packages: { + '@pnpm/exe@9.3.0': { + resolution: { + integrity: 'sha512-exe', + }, + }, + '@pnpm/linux-x64@9.3.0': { + resolution: { + integrity: 'sha512-linux-x64', + }, + }, + 'pnpm@9.3.0': { + resolution: { + integrity: 'sha512-pnpm', + }, + }, + }, + snapshots: { + '@pnpm/exe@9.3.0': { + optionalDependencies: { + '@pnpm/linux-x64': '9.3.0', + }, + }, + '@pnpm/linux-x64@9.3.0': { + optional: true, + }, + 'pnpm@9.3.0': {}, + }, +} +const installPnpmToStore = jest.fn<(version: string, opts: object) => Promise<{ binDir: string }>>(async () => ({ binDir: '/store/bin' })) +const readEnvLockfile = jest.fn<(rootDir: string) => Promise>(async () => envLockfile) +const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: object) => Promise>(async () => envLockfile) +const spawnSync = jest.fn(() => ({ status: 0 })) + +jest.unstable_mockModule('@pnpm/cli.meta', () => ({ + packageManager: { name: 'pnpm', version: '11.0.0' }, +})) +jest.unstable_mockModule('@pnpm/engine.pm.commands', () => ({ + installPnpmToStore, +})) +jest.unstable_mockModule('@pnpm/installing.env-installer', () => ({ + isPackageManagerResolved: () => true, + resolvePackageManagerIntegrities, +})) +jest.unstable_mockModule('@pnpm/lockfile.fs', () => ({ + readEnvLockfile, +})) +jest.unstable_mockModule('@pnpm/shell.path', () => ({ + prependDirsToPath: () => ({ name: 'PATH', updated: true, value: '/store/bin' }), +})) +jest.unstable_mockModule('@pnpm/store.connection-manager', () => ({ + createStoreController, +})) +jest.unstable_mockModule('cross-spawn', () => ({ + default: { sync: spawnSync }, +})) + +const { switchCliVersion } = await import('./switchCliVersion.js') + +beforeEach(() => { + closeStore.mockClear() + createStoreController.mockClear() + installPnpmToStore.mockClear() + readEnvLockfile.mockClear() + readEnvLockfile.mockResolvedValue(envLockfile) + resolvePackageManagerIntegrities.mockClear() + resolvePackageManagerIntegrities.mockResolvedValue(envLockfile) + spawnSync.mockClear() +}) + +test('switchCliVersion uses trusted package-manager registries instead of project registries', async () => { + const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => { + throw new Error(`exit ${code ?? 0}`) + }) as typeof process.exit) + + const projectRegistries = { + '@pnpm': 'https://project-pnpm.example.com/', + default: 'https://project.example.com/', + } + const packageManagerRegistries = { + '@pnpm': 'https://trusted-pnpm.example.com/', + default: 'https://trusted.example.com/', + } + const packageManagerNetworkConfig = { + configByUri: { + '//trusted.example.com/': { + creds: { authToken: 'trusted-token' }, + }, + }, + httpProxy: 'http://trusted-http-proxy.example.com:8080', + httpsProxy: 'http://trusted-https-proxy.example.com:8080', + noProxy: 'trusted.internal', + strictSsl: true, + } + const config = { + configByUri: { + '//project.example.com/': { + creds: { authToken: 'project-token' }, + }, + }, + httpProxy: 'http://project-http-proxy.example.com:8080', + httpsProxy: 'http://project-https-proxy.example.com:8080', + noProxy: 'project.internal', + packageManagerRegistries, + packageManagerNetworkConfig, + registries: projectRegistries, + strictSsl: false, + virtualStoreDirMaxLength: 120, + } as unknown as Config + const context = { + rootProjectManifestDir: '/repo', + wantedPackageManager: { + fromDevEngines: true, + name: 'pnpm', + onFail: 'download', + version: '9.3.0', + }, + } as unknown as ConfigContext + + await expect(switchCliVersion(config, context)).rejects.toThrow('exit 0') + + expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({ + configByUri: packageManagerNetworkConfig.configByUri, + httpProxy: packageManagerNetworkConfig.httpProxy, + httpsProxy: packageManagerNetworkConfig.httpsProxy, + noProxy: packageManagerNetworkConfig.noProxy, + registries: packageManagerRegistries, + strictSsl: packageManagerNetworkConfig.strictSsl, + })) + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() + expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({ + registries: packageManagerRegistries, + })) + expect(installPnpmToStore).not.toHaveBeenCalledWith('9.3.0', expect.objectContaining({ + registries: projectRegistries, + })) + + exit.mockRestore() +}) + +test('switchCliVersion defaults package-manager registries to npmjs instead of project registries', async () => { + const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => { + throw new Error(`exit ${code ?? 0}`) + }) as typeof process.exit) + + const projectRegistries = { + '@pnpm': 'https://project-pnpm.example.com/', + default: 'https://project.example.com/', + } + const config = { + configByUri: { + '//project.example.com/': { + creds: { authToken: 'project-token' }, + }, + }, + httpProxy: 'http://project-http-proxy.example.com:8080', + httpsProxy: 'http://project-https-proxy.example.com:8080', + noProxy: 'project.internal', + registries: projectRegistries, + strictSsl: false, + virtualStoreDirMaxLength: 120, + } as unknown as Config + const context = { + rootProjectManifestDir: '/repo', + wantedPackageManager: { + fromDevEngines: true, + name: 'pnpm', + onFail: 'download', + version: '9.3.0', + }, + } as unknown as ConfigContext + + await expect(switchCliVersion(config, context)).rejects.toThrow('exit 0') + + expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({ + configByUri: {}, + httpProxy: undefined, + httpsProxy: undefined, + noProxy: undefined, + registries: { default: 'https://registry.npmjs.org/' }, + strictSsl: undefined, + })) + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() + expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({ + registries: { default: 'https://registry.npmjs.org/' }, + })) + expect(installPnpmToStore).not.toHaveBeenCalledWith('9.3.0', expect.objectContaining({ + registries: projectRegistries, + })) + + exit.mockRestore() +}) + +test('switchCliVersion installs from a registry-only package-manager lockfile without re-resolving', async () => { + const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => { + throw new Error(`exit ${code ?? 0}`) + }) as typeof process.exit) + + await expect(switchCliVersion({ + registries: { default: 'https://registry.npmjs.org/' }, + virtualStoreDirMaxLength: 120, + } as unknown as Config, { + rootProjectManifestDir: '/repo', + wantedPackageManager: { + fromDevEngines: true, + name: 'pnpm', + onFail: 'download', + version: '9.3.0', + }, + } as unknown as ConfigContext)).rejects.toThrow('exit 0') + + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() + expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({ + envLockfile, + })) + + exit.mockRestore() +}) + +test('switchCliVersion accepts registry-only package-manager lockfiles with peer-suffixed snapshots', async () => { + const exit = jest.spyOn(process, 'exit').mockImplementation(((code?: string | number | null | undefined) => { + throw new Error(`exit ${code ?? 0}`) + }) as typeof process.exit) + const peerLockfile: EnvLockfile = { + ...envLockfile, + packages: { + ...envLockfile.packages, + '@pnpm/linux-x64@9.3.0': { + resolution: { + integrity: 'sha512-linux-x64', + }, + }, + 'peer-provider@1.0.0': { + resolution: { + integrity: 'sha512-peer-provider', + }, + }, + }, + snapshots: { + ...envLockfile.snapshots, + '@pnpm/exe@9.3.0': { + optionalDependencies: { + '@pnpm/linux-x64': '9.3.0(peer-provider@1.0.0)', + }, + }, + '@pnpm/linux-x64@9.3.0(peer-provider@1.0.0)': { + dependencies: { + 'peer-provider': '1.0.0', + }, + optional: true, + }, + 'peer-provider@1.0.0': {}, + }, + } + + readEnvLockfile.mockResolvedValueOnce(peerLockfile) + + await expect(switchCliVersion({ + registries: { default: 'https://registry.npmjs.org/' }, + virtualStoreDirMaxLength: 120, + } as unknown as Config, { + rootProjectManifestDir: '/repo', + wantedPackageManager: { + fromDevEngines: true, + name: 'pnpm', + onFail: 'download', + version: '9.3.0', + }, + } as unknown as ConfigContext)).rejects.toThrow('exit 0') + + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() + expect(installPnpmToStore).toHaveBeenCalledWith('9.3.0', expect.objectContaining({ + envLockfile: peerLockfile, + })) + + exit.mockRestore() +}) + +test('switchCliVersion rejects package-manager lockfile resolutions with non-integrity fields', async () => { + const poisonedLockfile: EnvLockfile = { + ...envLockfile, + packages: { + ...envLockfile.packages, + '@pnpm/linux-x64@9.3.0': { + resolution: { + integrity: 'sha512-poisoned', + tarball: 'https://evil.example.com/pnpm-linux-x64.tgz', + }, + }, + }, + } + + readEnvLockfile.mockResolvedValueOnce(poisonedLockfile) + + await expect(switchCliVersion({ + registries: { default: 'https://registry.npmjs.org/' }, + virtualStoreDirMaxLength: 120, + } as unknown as Config, { + rootProjectManifestDir: '/repo', + wantedPackageManager: { + fromDevEngines: true, + name: 'pnpm', + onFail: 'download', + version: '9.3.0', + }, + } as unknown as ConfigContext)).rejects.toThrow('integrity-only resolution') + + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() + expect(createStoreController).not.toHaveBeenCalled() + expect(installPnpmToStore).not.toHaveBeenCalled() + expect(spawnSync).not.toHaveBeenCalled() +}) + +test('switchCliVersion rejects package-manager lockfile dependencies with non-registry dep paths', async () => { + const poisonedLockfile: EnvLockfile = { + ...envLockfile, + packages: { + ...envLockfile.packages, + 'payload@file:../payload.tgz': { + resolution: { + integrity: 'sha512-payload', + }, + }, + }, + snapshots: { + ...envLockfile.snapshots, + 'pnpm@9.3.0': { + dependencies: { + payload: 'file:../payload.tgz', + }, + }, + 'payload@file:../payload.tgz': {}, + }, + } + + readEnvLockfile.mockResolvedValueOnce(poisonedLockfile) + + await expect(switchCliVersion({ + registries: { default: 'https://registry.npmjs.org/' }, + virtualStoreDirMaxLength: 120, + } as unknown as Config, { + rootProjectManifestDir: '/repo', + wantedPackageManager: { + fromDevEngines: true, + name: 'pnpm', + onFail: 'download', + version: '9.3.0', + }, + } as unknown as ConfigContext)).rejects.toThrow('registry package path') + + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalled() + expect(createStoreController).not.toHaveBeenCalled() + expect(installPnpmToStore).not.toHaveBeenCalled() + expect(spawnSync).not.toHaveBeenCalled() +}) diff --git a/pnpm/src/switchCliVersion.ts b/pnpm/src/switchCliVersion.ts index f107666b3f..f7a0176b78 100644 --- a/pnpm/src/switchCliVersion.ts +++ b/pnpm/src/switchCliVersion.ts @@ -13,6 +13,8 @@ import spawn from 'cross-spawn' import semver from 'semver' import { exit } from './exit.js' +import { assertPackageManagerLockfileUsesRegistryResolutions } from './packageManagerLockfile.js' +import { getPackageManagerBootstrapConfig } from './packageManagerRegistries.js' export async function switchCliVersion (config: Config, context: ConfigContext): Promise { const pm = context.wantedPackageManager @@ -30,15 +32,16 @@ export async function switchCliVersion (config: Config, context: ConfigContext): ? (await readEnvLockfile(context.rootProjectManifestDir) ?? undefined) : undefined let storeToUse: Awaited> | undefined + const packageManagerConfig = getPackageManagerBootstrapConfig(config) // Check if the env lockfile already has a resolved version that satisfies the wanted version/range. let pmVersion = envLockfile?.importers['.'].packageManagerDependencies?.['pnpm']?.version if (!pmVersion || !semver.satisfies(pmVersion, pm.version, { includePrerelease: true })) { // Resolve to an exact version from the registry. - storeToUse = await createStoreController({ ...config, ...context }) + storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig }) envLockfile = await resolvePackageManagerIntegrities(pm.version, { envLockfile, - registries: config.registries, + registries: packageManagerConfig.registries, rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, @@ -51,10 +54,10 @@ export async function switchCliVersion (config: Config, context: ConfigContext): return } } else if (!isPackageManagerResolved(envLockfile, pmVersion)) { - storeToUse = await createStoreController({ ...config, ...context }) + storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig }) envLockfile = await resolvePackageManagerIntegrities(pmVersion, { envLockfile, - registries: config.registries, + registries: packageManagerConfig.registries, rootDir: context.rootProjectManifestDir, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, @@ -69,15 +72,22 @@ export async function switchCliVersion (config: Config, context: ConfigContext): return } + if (!envLockfile) { + await storeToUse?.ctrl.close() + throw new PnpmError('NO_PKG_MANAGER_INTEGRITY', `The packageManager dependency ${pmVersion} was not found in pnpm-lock.yaml`) + } + + try { + assertPackageManagerLockfileUsesRegistryResolutions(envLockfile) + } catch (err: unknown) { + await storeToUse?.ctrl.close() + throw err + } + // We need a store controller to install pnpm. If it wasn't created during // integrity resolution (because integrities were already cached), create it now. if (!storeToUse) { - storeToUse = await createStoreController({ ...config, ...context }) - } - - if (!envLockfile) { - await storeToUse.ctrl.close() - throw new PnpmError('NO_PKG_MANAGER_INTEGRITY', `The packageManager dependency ${pmVersion} was not found in pnpm-lock.yaml`) + storeToUse = await createStoreController({ ...config, ...context, ...packageManagerConfig }) } let wantedPnpmBinDir: string @@ -86,7 +96,7 @@ export async function switchCliVersion (config: Config, context: ConfigContext): envLockfile, storeController: storeToUse.ctrl, storeDir: storeToUse.dir, - registries: config.registries, + registries: packageManagerConfig.registries, virtualStoreDirMaxLength: config.virtualStoreDirMaxLength, packageManager: { name: packageManager.name, version: packageManager.version }, // Network settings so the engine identity check can reach the canonical diff --git a/pnpm/src/syncEnvLockfile.test.ts b/pnpm/src/syncEnvLockfile.test.ts index e2c8705f27..4f5e8c4bcb 100644 --- a/pnpm/src/syncEnvLockfile.test.ts +++ b/pnpm/src/syncEnvLockfile.test.ts @@ -11,7 +11,7 @@ import { tempDir } from '@pnpm/prepare' // Simulate what the real resolvePackageManagerIntegrities does that this test // cares about: record the resolved pnpm version under // packageManagerDependencies and persist the lockfile to disk. -const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: { envLockfile?: EnvLockfile, rootDir: string, save?: boolean }) => Promise>( +const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: { envLockfile?: EnvLockfile, registries?: unknown, rootDir: string, save?: boolean }) => Promise>( async (version, opts) => { const lockfile = opts.envLockfile ?? ({ lockfileVersion: '9.0', importers: { '.': { configDependencies: {} } }, packages: {}, snapshots: {} } as EnvLockfile) lockfile.importers['.'].packageManagerDependencies = { @@ -135,6 +135,99 @@ test('updates the lockfile when locked version no longer satisfies wanted versio }) }) +test('uses trusted package-manager registries instead of project registries', async () => { + const dir = tempDir() + const projectRegistries = { + '@pnpm': 'https://project-pnpm.example.com/', + default: 'https://project.example.com/', + } + const packageManagerRegistries = { + '@pnpm': 'https://trusted-pnpm.example.com/', + default: 'https://trusted.example.com/', + } + const packageManagerNetworkConfig = { + configByUri: { + '//trusted.example.com/': { + creds: { authToken: 'trusted-token' }, + }, + }, + httpProxy: 'http://trusted-http-proxy.example.com:8080', + httpsProxy: 'http://trusted-https-proxy.example.com:8080', + noProxy: 'trusted.internal', + strictSsl: true, + } + + await syncEnvLockfile({ + configByUri: { + '//project.example.com/': { + creds: { authToken: 'project-token' }, + }, + }, + httpProxy: 'http://project-http-proxy.example.com:8080', + httpsProxy: 'http://project-https-proxy.example.com:8080', + noProxy: 'project.internal', + packageManagerRegistries, + packageManagerNetworkConfig, + registries: projectRegistries, + strictSsl: false, + } as unknown as Config, makeContext(dir, { + wantedPackageManager: { name: 'pnpm', version: packageManager.version, fromDevEngines: true }, + })) + + expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({ + configByUri: packageManagerNetworkConfig.configByUri, + httpProxy: packageManagerNetworkConfig.httpProxy, + httpsProxy: packageManagerNetworkConfig.httpsProxy, + noProxy: packageManagerNetworkConfig.noProxy, + registries: packageManagerRegistries, + strictSsl: packageManagerNetworkConfig.strictSsl, + })) + expect(resolvePackageManagerIntegrities).toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ + registries: packageManagerRegistries, + })) + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ + registries: projectRegistries, + })) +}) + +test('defaults package-manager registries to npmjs instead of project registries', async () => { + const dir = tempDir() + const projectRegistries = { + '@pnpm': 'https://project-pnpm.example.com/', + default: 'https://project.example.com/', + } + + await syncEnvLockfile({ + configByUri: { + '//project.example.com/': { + creds: { authToken: 'project-token' }, + }, + }, + httpProxy: 'http://project-http-proxy.example.com:8080', + httpsProxy: 'http://project-https-proxy.example.com:8080', + noProxy: 'project.internal', + registries: projectRegistries, + strictSsl: false, + } as unknown as Config, makeContext(dir, { + wantedPackageManager: { name: 'pnpm', version: packageManager.version, fromDevEngines: true }, + })) + + expect(createStoreController).toHaveBeenCalledWith(expect.objectContaining({ + configByUri: {}, + httpProxy: undefined, + httpsProxy: undefined, + noProxy: undefined, + registries: { default: 'https://registry.npmjs.org/' }, + strictSsl: undefined, + })) + expect(resolvePackageManagerIntegrities).toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ + registries: { default: 'https://registry.npmjs.org/' }, + })) + expect(resolvePackageManagerIntegrities).not.toHaveBeenCalledWith(packageManager.version, expect.objectContaining({ + registries: projectRegistries, + })) +}) + function writeStaleEnvLockfile (dir: string, pnpmVersion: string): void { // readEnvLockfile expects a multi-document YAML file beginning with `---\n`, // where the env lockfile is the first document. diff --git a/pnpm/src/syncEnvLockfile.ts b/pnpm/src/syncEnvLockfile.ts index f52dbdfacb..1c83a6aa4b 100644 --- a/pnpm/src/syncEnvLockfile.ts +++ b/pnpm/src/syncEnvLockfile.ts @@ -5,6 +5,8 @@ import { readEnvLockfile } from '@pnpm/lockfile.fs' import { createStoreController } from '@pnpm/store.connection-manager' import semver from 'semver' +import { getPackageManagerBootstrapConfig } from './packageManagerRegistries.js' + /** * Records the currently running pnpm version in the env lockfile's * `packageManagerDependencies` entry when the project opts in to @@ -31,11 +33,12 @@ export async function syncEnvLockfile (config: Config, context: ConfigContext): const lockedVersion = envLockfile?.importers['.'].packageManagerDependencies?.['pnpm']?.version if (lockedVersion != null && semver.satisfies(lockedVersion, pm.version, { includePrerelease: true })) return - const store = await createStoreController({ ...config, ...context }) + const packageManagerConfig = getPackageManagerBootstrapConfig(config) + const store = await createStoreController({ ...config, ...context, ...packageManagerConfig }) try { await resolvePackageManagerIntegrities(packageManager.version, { envLockfile: envLockfile ?? undefined, - registries: config.registries, + registries: packageManagerConfig.registries, rootDir: context.rootProjectManifestDir, storeController: store.ctrl, storeDir: store.dir, diff --git a/pnpm/test/getConfig.test.ts b/pnpm/test/getConfig.test.ts index 0e3e26dee3..b192663b24 100644 --- a/pnpm/test/getConfig.test.ts +++ b/pnpm/test/getConfig.test.ts @@ -49,7 +49,7 @@ afterEach(() => { jest.mocked(console.warn).mockRestore() }) -test('console a warning when the .npmrc has an env variable that does not exist', async () => { +test('console a warning when the .npmrc has an env variable in a project-level registry', async () => { prepare() fs.writeFileSync('.npmrc', 'registry=${ENV_VAR_123}', 'utf8') @@ -61,7 +61,7 @@ test('console a warning when the .npmrc has an env variable that does not exist' excludeReporter: false, }) - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to replace env in config: ${ENV_VAR_123}')) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Ignored project-level request destination "registry"')) }) describe('calcPnpmfilePathsOfPluginDeps', () => {