From 3116ed799b7eedc44a1570aadbd31b08c39d8074 Mon Sep 17 00:00:00 2001 From: fkj Date: Wed, 7 Oct 2026 20:18:34 +0000 Subject: [PATCH 1/2] chore(fork): touch the Corepack entry point to record its baseline (cherry picked from commit 8623dc9443bf66555ba0705380982bf3b88f63bc) --- pnpm/npm/pnpm/bin/pnpm.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm/npm/pnpm/bin/pnpm.mjs b/pnpm/npm/pnpm/bin/pnpm.mjs index df0fa9c7363..a56cee5a42c 100644 --- a/pnpm/npm/pnpm/bin/pnpm.mjs +++ b/pnpm/npm/pnpm/bin/pnpm.mjs @@ -145,3 +145,5 @@ function fail (message) { console.error(message) process.exit(1) } + +// Recorded by the fork-only Garnet Bootstrap Routing workflow. From cc7b241a5fd59733fcb315019b391188678243f1 Mon Sep 17 00:00:00 2001 From: fkj Date: Wed, 7 Oct 2026 20:25:31 +0000 Subject: [PATCH 2/2] fix(pnpm): download the native binary from the configured registry Resolve the native binary download against the configured registry, including a project .npmrc scope registry, and keep verifying the registry signature. (cherry picked from commit 5923d1c19e72a4e4ac592bf3cb67b1ce23ad182c) --- .changeset/bootstrap-download-reads-npmrc.md | 5 + pnpm/npm/pnpm/bin/pnpm.mjs | 189 +++++++++++++++++-- pnpm/npm/pnpm/test/corepack-entry.test.mjs | 141 +++++++++++++- pnpm/npm/pnpm/test/registry-fixture.mjs | 5 +- 4 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 .changeset/bootstrap-download-reads-npmrc.md diff --git a/.changeset/bootstrap-download-reads-npmrc.md b/.changeset/bootstrap-download-reads-npmrc.md new file mode 100644 index 00000000000..107803c1be9 --- /dev/null +++ b/.changeset/bootstrap-download-reads-npmrc.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +When the `pnpm` package has to download its native binary on first run, it now uses the registry and credentials from `.npmrc` and from the `npm_config_registry` and `pnpm_config_registry` environment variables. `COREPACK_NPM_REGISTRY` still takes precedence. A project `.npmrc` is not read when `COREPACK_INTEGRITY_KEYS` turns off the signature check [#16655](https://github.com/pnpm/pnpm/issues/16655). diff --git a/pnpm/npm/pnpm/bin/pnpm.mjs b/pnpm/npm/pnpm/bin/pnpm.mjs index a56cee5a42c..8b24c8ee037 100644 --- a/pnpm/npm/pnpm/bin/pnpm.mjs +++ b/pnpm/npm/pnpm/bin/pnpm.mjs @@ -17,12 +17,13 @@ // // The download itself is `get-pnpm`, the package behind https://get.pnpm.io, // which already knows how to verify one; it travels in that same `dist/` -// payload. What is left here is Corepack's environment, which it does not know: -// where to download from, what credentials to use, and whose signature to trust. +// payload. What is left here is what it does not know: where to download from, +// what credentials to use, and whose signature to trust. import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' import console from 'node:console' import fs from 'node:fs' +import net from 'node:net' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -78,13 +79,13 @@ async function nativeBinary () { const { downloadPnpmExecutable } = await import(GET_PNPM).catch((err) => { fail(`This copy of the pnpm package is missing the downloader it needs: ${err.message}`) }) + const signature = signaturePolicy() try { await downloadPnpmExecutable({ version, - registry: process.env.COREPACK_NPM_REGISTRY || DEFAULT_REGISTRY, destPath: DOWNLOADED_BINARY, - headers: registryHeaders(), - ...signaturePolicy(), + ...registryAccess({ readProjectNpmrc: signature.verifySignature !== false }), + ...signature, }) } catch (err) { fail(`Could not download the pnpm ${version} binary: ${err.message}`) @@ -93,22 +94,188 @@ async function nativeBinary () { } /** - * Credentials for the registry, read the way Corepack reads its own. `get-pnpm` - * keeps them on that registry's origin, so a download host it names never - * receives them. + * The registry to download from, and the headers to send it. Corepack's + * `COREPACK_NPM_REGISTRY` wins. Otherwise npm's sources decide, highest first: + * the environment, the project `.npmrc`, the user `.npmrc`. + * + * The project `.npmrc` is read only with `readProjectNpmrc`, which the caller + * passes while the download is checked against npm's signature. The signature + * pins the bytes, so a repository picks only where they come from. Unchecked, + * it would pick the binary itself, which is kept next to this wrapper and run + * in every later project (GHSA-j2hc-m6cf-6jm8). `get-pnpm` keeps the headers on + * the registry's origin, so a download host it names never receives them. */ -function registryHeaders () { +function registryAccess ({ readProjectNpmrc }) { + const corepackRegistry = process.env.COREPACK_NPM_REGISTRY + if (corepackRegistry) { + return { registry: corepackRegistry, headers: corepackHeaders() } + } + const npmrc = readUserNpmrc() + if (readProjectNpmrc) { + Object.assign(npmrc, readNpmrc(path.join(projectDir(), '.npmrc'), projectEntry)) + } + const registry = npmrc['@pnpm:registry'] || settingFromEnv('registry') || npmrc.registry + if (!registry) { + return { registry: DEFAULT_REGISTRY, headers: corepackHeaders() } + } + return { registry, headers: npmrcHeaders(npmrc, registry) } +} + +/** Credentials for Corepack's registry, read the way Corepack reads its own. */ +function corepackHeaders () { const { COREPACK_NPM_TOKEN, COREPACK_NPM_USERNAME, COREPACK_NPM_PASSWORD } = process.env if (COREPACK_NPM_TOKEN) { return { authorization: `Bearer ${COREPACK_NPM_TOKEN}` } } if (COREPACK_NPM_USERNAME && COREPACK_NPM_PASSWORD) { - const credentials = Buffer.from(`${COREPACK_NPM_USERNAME}:${COREPACK_NPM_PASSWORD}`, 'utf8') - return { authorization: `Basic ${credentials.toString('base64')}` } + return { authorization: `Basic ${base64(`${COREPACK_NPM_USERNAME}:${COREPACK_NPM_PASSWORD}`)}` } } return undefined } +function settingFromEnv (key) { + const upper = key.toUpperCase() + const { env } = process + return env[`pnpm_config_${key}`] || env[`PNPM_CONFIG_${upper}`] || + env[`npm_config_${key}`] || env[`NPM_CONFIG_${upper}`] || undefined +} + +/** The user `.npmrc`, with `${VAR}` placeholders expanded. */ +function readUserNpmrc () { + return readNpmrc( + settingFromEnv('userconfig') || path.join(os.homedir(), '.npmrc'), + (key, value) => [expandEnv(key), expandEnv(value)] + ) +} + +/** + * A project `.npmrc` entry, which is not expanded. One with a `${VAR}` + * placeholder in a registry setting or a credential is dropped, as pnpm drops + * it: a repository must not route the user's environment to a registry. + */ +function projectEntry (key, value, file) { + if (!key.includes('${') && !value.includes('${')) { + return [key, value] + } + if (key.startsWith('//') || key === 'registry' || key.endsWith(':registry')) { + console.error(`Ignored ${key} from ${file}: a project .npmrc cannot use environment variables in registry settings or credentials.`) + } + return undefined +} + +/** Where pnpm reads the project `.npmrc`: the workspace root, or else the current directory. */ +function projectDir () { + const cwd = process.cwd() + for (let dir = cwd; ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + return dir + } + if (path.dirname(dir) === dir) { + return cwd + } + } +} + +/** + * The top-level settings of the `.npmrc` at `file`, each passed through + * `parseEntry`, which returns the `[key, value]` to keep or `undefined`. + */ +function readNpmrc (file, parseEntry) { + const settings = Object.create(null) + let text + try { + text = fs.readFileSync(file, 'utf8') + } catch (err) { + if (err.code !== 'ENOENT' && err.code !== 'EISDIR') { + console.error(`Could not read ${file}, so its settings are not used: ${err.message}`) + } + return settings + } + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + // Settings under a `[section]` are not top-level ones. + if (line.startsWith('[')) { + break + } + const separator = line.indexOf('=') + if (separator === -1 || line.startsWith(';') || line.startsWith('#')) { + continue + } + const entry = parseEntry(line.slice(0, separator).trim(), unquote(line.slice(separator + 1).trim()), file) + if (entry != null) { + settings[entry[0]] = entry[1] + } + } + return settings +} + +function unquote (value) { + const quoted = value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0] + return quoted ? value.slice(1, -1) : value +} + +function expandEnv (value) { + return value.replace(/\$\{([^${}]+)\}/g, (_, name) => process.env[name] ?? '') +} + +/** + * The credentials the `.npmrc` holds for `registry`, looked up as pnpm looks + * them up for an `@pnpm` package: the `@pnpm` scope's own credentials first, + * then the registry-wide ones. Each is the longest `//host[:port]/path/` + * prefix of the registry URL that has any, then the same without the port. + * They are sent only over HTTPS or to the local machine. + */ +function npmrcHeaders (npmrc, registry) { + const url = new URL(registry.endsWith('/') ? registry : `${registry}/`) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url.hostname))) { + return undefined + } + const prefixes = [] + for (const host of new Set([url.host, url.hostname])) { + for (let pathname = url.pathname; ; pathname = pathname.slice(0, pathname.lastIndexOf('/', pathname.length - 2) + 1)) { + prefixes.push(`//${host}${pathname}`) + if (pathname === '/') { + break + } + } + } + for (const scope of [':@pnpm', '']) { + for (const prefix of prefixes) { + const authorization = npmrcAuthorization(npmrc, `${prefix}${scope}`) + if (authorization != null) { + return { authorization } + } + } + } + return undefined +} + +function isLoopback (hostname) { + return hostname === 'localhost' || hostname === '[::1]' || + (net.isIPv4(hostname) && hostname.startsWith('127.')) +} + +function npmrcAuthorization (npmrc, prefix) { + const token = npmrc[`${prefix}:_authToken`] + if (token) { + return `Bearer ${token}` + } + const auth = npmrc[`${prefix}:_auth`] + if (auth) { + return `Basic ${auth}` + } + const username = npmrc[`${prefix}:username`] + const password = npmrc[`${prefix}:_password`] + if (username && password) { + return `Basic ${base64(`${username}:${Buffer.from(password, 'base64').toString('utf8')}`)}` + } + return undefined +} + +function base64 (text) { + return Buffer.from(text, 'utf8').toString('base64') +} + /** * Whose signature over the download to trust, following `COREPACK_INTEGRITY_KEYS` * exactly as Corepack does: npm's own keys when it is unset, the keys it names diff --git a/pnpm/npm/pnpm/test/corepack-entry.test.mjs b/pnpm/npm/pnpm/test/corepack-entry.test.mjs index 83e0938e48d..d496c9df631 100644 --- a/pnpm/npm/pnpm/test/corepack-entry.test.mjs +++ b/pnpm/npm/pnpm/test/corepack-entry.test.mjs @@ -14,7 +14,7 @@ import os from 'node:os' import path from 'node:path' import process from 'node:process' import { after, describe, it } from 'node:test' -import { fileURLToPath } from 'node:url' +import { URL, fileURLToPath } from 'node:url' import { INTEGRITY_KEYS, VERSION, binFile, packageName, startRegistry } from './registry-fixture.mjs' @@ -33,6 +33,14 @@ echo "ran: $*" const SPAWNS_THE_BINARY = process.platform === 'win32' && 'the stand-in for the native binary is a shell script, which Windows cannot spawn' const DOWNLOADED_BINARY = process.platform === 'win32' ? 'pnpm-native.exe' : 'pnpm-native' +// The variables that could name a registry or a user `.npmrc` from the +// environment the tests run in. +const ENV_NPM_CONFIG = ['registry', 'userconfig'].flatMap((name) => [ + `pnpm_config_${name}`, + `PNPM_CONFIG_${name.toUpperCase()}`, + `npm_config_${name}`, + `NPM_CONFIG_${name.toUpperCase()}`, +]) describe('corepack entry point', () => { it('downloads the native binary on first use, then reuses it', { skip: SPAWNS_THE_BINARY }, async () => { @@ -128,6 +136,91 @@ describe('corepack entry point', () => { assert.match(empty.stderr, /no "npm" key set/) }) + it('downloads from the registry the user .npmrc names, with its credentials', async () => { + const fixture = await createFixture() + const userNpmrc = writeNpmrc(fixture, 'user.npmrc', [ + `registry=${fixture.registryUrl}/`, + `${nerfDart(fixture.registryUrl)}:_authToken=\${FIXTURE_TOKEN}`, + ]) + + const result = await runEntry(fixture, 'bin/pnpm.mjs', ['--version'], { + ...userNpmConfig(userNpmrc), + FIXTURE_TOKEN: 'user-token', + COREPACK_NPM_TOKEN: 'corepack-token', + }) + assert.match(result.stderr, /Downloading/) + assert.deepEqual(new Set(fixture.requests.map(({ url, authorization }) => `${url} ${authorization}`)), new Set([ + `/${packageName}/${VERSION} Bearer user-token`, + `/${packageName}/-/${VERSION}.tgz Bearer user-token`, + ])) + }) + + it('takes the @pnpm scope registry over the default one', async () => { + const fixture = await createFixture() + const userNpmrc = writeNpmrc(fixture, 'user.npmrc', [`@pnpm:registry=${fixture.registryUrl}/`]) + + await runEntry(fixture, 'bin/pnpm.mjs', ['--version'], { + ...userNpmConfig(userNpmrc), + npm_config_registry: 'http://127.0.0.1:9/', + }) + assert.ok(fixture.requests.some(({ url }) => url === `/${packageName}/${VERSION}`)) + }) + + it('sends the @pnpm scope credentials over the registry-wide ones', async () => { + const fixture = await createFixture() + const userNpmrc = writeNpmrc(fixture, 'user.npmrc', [ + `@pnpm:registry=${fixture.registryUrl}/`, + `${nerfDart(fixture.registryUrl)}:_authToken=registry-token`, + `${nerfDart(fixture.registryUrl)}:@pnpm:_authToken=scope-token`, + ]) + + await runEntry(fixture, 'bin/pnpm.mjs', ['--version'], userNpmConfig(userNpmrc)) + assert.deepEqual(new Set(fixture.requests.map(({ url, authorization }) => `${url} ${authorization}`)), new Set([ + `/${packageName}/${VERSION} Bearer scope-token`, + `/${packageName}/-/${VERSION}.tgz Bearer scope-token`, + ])) + }) + + it('downloads from the registry the workspace .npmrc names, without expanding its credentials', async () => { + const fixture = await createFixture() + const userRegistry = await startRegistry({ payload: FAKE_BINARY }) + after(userRegistry.close) + fs.writeFileSync(path.join(fixture.projectDir, 'pnpm-workspace.yaml'), '') + writeNpmrc(fixture, 'project/.npmrc', [ + `registry=${fixture.registryUrl}/`, + `${nerfDart(fixture.registryUrl)}:_authToken=\${FIXTURE_TOKEN}`, + ]) + const userNpmrc = writeNpmrc(fixture, 'user.npmrc', [`registry=${userRegistry.url}/`]) + const packageDir = path.join(fixture.projectDir, 'packages/a') + fs.mkdirSync(packageDir, { recursive: true }) + + const result = await runEntry({ ...fixture, projectDir: packageDir }, 'bin/pnpm.mjs', ['--version'], { + ...userNpmConfig(userNpmrc), + FIXTURE_TOKEN: 'user-token', + }) + assert.match(result.stderr, /Ignored .*:_authToken from .*\.npmrc/) + assert.deepEqual(userRegistry.requests, []) + assert.deepEqual(new Set(fixture.requests.map(({ url, authorization }) => `${url} ${authorization}`)), new Set([ + `/${packageName}/${VERSION} undefined`, + `/${packageName}/-/${VERSION}.tgz undefined`, + ])) + }) + + it('does not let a project .npmrc choose the registry of an unchecked download', async () => { + const fixture = await createFixture() + const projectRegistry = await startRegistry({ payload: FAKE_BINARY }) + after(projectRegistry.close) + writeNpmrc(fixture, 'project/.npmrc', [`registry=${projectRegistry.url}/`]) + const userNpmrc = writeNpmrc(fixture, 'user.npmrc', [`registry=${fixture.registryUrl}/`]) + + await runEntry(fixture, 'bin/pnpm.mjs', ['--version'], { + ...userNpmConfig(userNpmrc), + COREPACK_INTEGRITY_KEYS: '0', + }) + assert.deepEqual(projectRegistry.requests, []) + assert.ok(fixture.requests.some(({ url }) => url === `/${packageName}/${VERSION}`)) + }) + it('reports a disabled network instead of reaching for one', async () => { const fixture = await createFixture() @@ -138,15 +231,21 @@ describe('corepack entry point', () => { }) // Asynchronous on purpose: the registry runs in this process, so blocking on -// the child would deadlock the download. +// the child would deadlock the download. A variable `env` sets to `undefined` +// is left out of the child's environment. function runEntry (fixture, entry, args, env) { + const childEnv = { + ...process.env, + COREPACK_NPM_REGISTRY: fixture.registryUrl, + COREPACK_INTEGRITY_KEYS: INTEGRITY_KEYS, + ...env, + } + for (const [name, value] of Object.entries(childEnv)) { + if (value === undefined) delete childEnv[name] + } const child = spawn(process.execPath, [path.join(fixture.dir, entry), ...args], { - env: { - ...process.env, - COREPACK_NPM_REGISTRY: fixture.registryUrl, - COREPACK_INTEGRITY_KEYS: INTEGRITY_KEYS, - ...env, - }, + cwd: fixture.projectDir, + env: childEnv, }) let stdout = '' @@ -175,11 +274,35 @@ async function createFixture (registryOptions = {}) { } fs.cpSync(downloaderDir(), path.join(dir, BUNDLED_DOWNLOADER), { dereference: true, recursive: true }) fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'pnpm', version: VERSION })) + const projectDir = path.join(dir, 'project') + fs.mkdirSync(projectDir) const registry = await startRegistry({ payload: FAKE_BINARY, ...registryOptions }) after(registry.close) - return { dir, registryUrl: registry.url, closeRegistry: registry.close } + return { dir, projectDir, registryUrl: registry.url, requests: registry.requests, closeRegistry: registry.close } +} + +function writeNpmrc (fixture, file, lines) { + const npmrcPath = path.join(fixture.dir, file) + fs.writeFileSync(npmrcPath, `${lines.join('\n')}\n`) + return npmrcPath +} + +/** + * An environment whose only npm configuration is `userNpmrc`, with no registry + * named by Corepack or by an environment variable. + */ +function userNpmConfig (userNpmrc) { + return { + ...Object.fromEntries(ENV_NPM_CONFIG.map((name) => [name, undefined])), + COREPACK_NPM_REGISTRY: undefined, + npm_config_userconfig: userNpmrc, + } +} + +function nerfDart (registryUrl) { + return `//${new URL(registryUrl).host}/` } /** The installed get-pnpm, which the release bundles into `dist/`. */ diff --git a/pnpm/npm/pnpm/test/registry-fixture.mjs b/pnpm/npm/pnpm/test/registry-fixture.mjs index 0bf745605da..3bbc0284613 100644 --- a/pnpm/npm/pnpm/test/registry-fixture.mjs +++ b/pnpm/npm/pnpm/test/registry-fixture.mjs @@ -40,7 +40,7 @@ export const INTEGRITY_KEYS = JSON.stringify({ * @param {Buffer | string} opts.payload Contents of the binary inside the tarball. * @param {boolean} [opts.tamper] Serve bytes the published checksum does not cover. * @param {boolean} [opts.unsigned] Publish the package without a signature. - * @returns {Promise<{url: string, close: () => Promise}>} + * @returns {Promise<{url: string, requests: Array<{url: string, authorization?: string}>, close: () => Promise}>} */ export function startRegistry ({ payload, tamper = false, unsigned = false }) { const tarball = zlib.gzipSync(tarArchive(`package/${binFile}`, Buffer.from(payload))) @@ -48,7 +48,9 @@ export function startRegistry ({ payload, tamper = false, unsigned = false }) { const integrity = `sha512-${createHash('sha512').update(tarball).digest('base64')}` const tarballPath = `/${packageName}/-/${VERSION}.tgz` + const requests = [] const server = http.createServer((req, res) => { + requests.push({ url: req.url, authorization: req.headers.authorization }) if (req.url === `/${packageName}/${VERSION}`) { res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ @@ -77,6 +79,7 @@ export function startRegistry ({ payload, tamper = false, unsigned = false }) { server.listen(0, '127.0.0.1', () => { resolve({ url: `http://127.0.0.1:${server.address().port}`, + requests, close: () => new Promise((closed) => { server.close(closed) // `close` alone waits out an idle keep-alive connection; absent