fix: harden package-manager bootstrap metadata (#12296)

- Resolve package-manager bootstrap metadata through trusted user/CLI registries and trusted network config, defaulting to the public npm registry instead of project/workspace registry settings.
- Apply that bootstrap config in `switchCliVersion()` and `syncEnvLockfile()` so repository `.npmrc` proxy/TLS/configByUri values cannot steer package-manager bootstrap traffic.
- Validate repository-provided package-manager env-lockfile entries before auto-switch install/execution: dependency paths must be registry package paths and package records must use integrity-only resolutions.
- Preserve the fast path for fully resolved, valid package-manager metadata; incomplete metadata is still resolved through trusted bootstrap registries.
- Handle peer-suffixed package-manager snapshots by looking up `packages` entries with `removeSuffix(depPath)` while keeping `snapshots` keyed by the full dep path.
This commit is contained in:
Zoltan Kochan
2026-06-10 00:30:31 +02:00
committed by GitHub
parent 5f2bb9f5ba
commit 822beb5fa0
12 changed files with 773 additions and 16 deletions

View File

@@ -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.

View File

@@ -18,6 +18,18 @@ export type UniversalOptions = Pick<Config, 'color' | 'dir' | 'authConfig'>
export type VerifyDepsBeforeRun = 'install' | 'warn' | 'error' | 'prompt' | false
export interface PackageManagerNetworkConfig {
ca?: string | string[]
cert?: string | string[]
configByUri: Record<string, RegistryConfig>
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<string, string>
configByUri: Record<string, RegistryConfig>
ignoreWorkspaceRootCheck: boolean

View File

@@ -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<string, unknown>,
configByUri: PackageManagerNetworkConfig['configByUri'],
env: Record<string, string | undefined>
): 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<string, string | undefined>, 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_<key>` 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

View File

@@ -18,6 +18,8 @@ export interface NpmrcConfigResult {
mergedConfig: Record<string, unknown>
/** Raw config suitable for pnpmConfig.authConfig (filtered through pickIniConfig by consumer) */
rawConfig: Record<string, unknown>
/** Non-project npmrc config used for package-manager bootstrap */
trustedConfig: Record<string, unknown>
/** Workspace .npmrc data */
workspaceNpmrc: Record<string, unknown>
/** User ~/.npmrc data (for token helpers) */
@@ -115,6 +117,15 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
}
}
const trustedConfig: Record<string, unknown> = {}
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,

View File

@@ -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 () => {

View File

@@ -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<DepPath>()
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<DepPath>
): 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`
)
}

View File

@@ -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<string, RegistryConfig>
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,
}
}

View File

@@ -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<void>>(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<EnvLockfile | null>>(async () => envLockfile)
const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: object) => Promise<EnvLockfile>>(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()
})

View File

@@ -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<void> {
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<ReturnType<typeof createStoreController>> | 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

View File

@@ -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<EnvLockfile>>(
const resolvePackageManagerIntegrities = jest.fn<(version: string, opts: { envLockfile?: EnvLockfile, registries?: unknown, rootDir: string, save?: boolean }) => Promise<EnvLockfile>>(
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.

View File

@@ -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,

View File

@@ -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', () => {