From d2677538cdb613fcab6d2a45bb07f349bdc65c2b Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 27 Mar 2024 19:23:43 +0100 Subject: [PATCH] fix: re-add support for custom registries with auth (#397) --- sources/corepackUtils.ts | 6 ++ sources/httpUtils.ts | 39 ++++++++-- tests/_registryServer.mjs | 155 ++++++++++++++++++++++++++++++++++++++ tests/_runCli.ts | 6 +- tests/main.test.ts | 54 ++++++++++++- 5 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 tests/_registryServer.mjs diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index 1c235020..479fe3d6 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -174,6 +174,12 @@ export async function installVersion(installTarget: string, locator: Locator, {s } } else { url = decodeURIComponent(version); + if (process.env.COREPACK_NPM_REGISTRY && url.startsWith(npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL)) { + url = url.replace( + npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL, + () => process.env.COREPACK_NPM_REGISTRY!, + ); + } } // Creating a temporary folder inside the install folder means that we diff --git a/sources/httpUtils.ts b/sources/httpUtils.ts index 2841907e..e995d1e9 100644 --- a/sources/httpUtils.ts +++ b/sources/httpUtils.ts @@ -1,8 +1,10 @@ -import assert from 'assert'; -import {UsageError} from 'clipanion'; -import {once} from 'events'; -import {stderr, stdin} from 'process'; -import {Readable} from 'stream'; +import assert from 'assert'; +import {UsageError} from 'clipanion'; +import {once} from 'events'; +import {stderr, stdin} from 'process'; +import {Readable} from 'stream'; + +import {DEFAULT_NPM_REGISTRY_URL} from './npmRegistryUtils'; async function fetch(input: string | URL, init?: RequestInit) { if (process.env.COREPACK_ENABLE_NETWORK === `0`) @@ -10,11 +12,38 @@ async function fetch(input: string | URL, init?: RequestInit) { const agent = await getProxyAgent(input); + if (typeof input === `string`) + input = new URL(input); + + let headers = init?.headers; + const {username, password} = input; + if (username || password) { + headers = { + ...headers, + authorization: `Bearer ${Buffer.from(`${username}:${password}`).toString(`base64`)}`, + }; + input.username = input.password = ``; + } else if (input.origin === process.env.COREPACK_NPM_REGISTRY || DEFAULT_NPM_REGISTRY_URL) { + if (process.env.COREPACK_NPM_TOKEN) { + headers = { + ...headers, + authorization: `Bearer ${process.env.COREPACK_NPM_TOKEN}`, + }; + } else if (`COREPACK_NPM_PASSWORD` in process.env) { + headers = { + ...headers, + authorization: `Bearer ${Buffer.from(`${process.env.COREPACK_NPM_USER}:${process.env.COREPACK_NPM_PASSWORD}`).toString(`base64`)}`, + }; + } + } + + let response; try { response = await globalThis.fetch(input, { ...init, dispatcher: agent, + headers, }); } catch (error) { throw new Error( diff --git a/tests/_registryServer.mjs b/tests/_registryServer.mjs new file mode 100644 index 00000000..9d62b99f --- /dev/null +++ b/tests/_registryServer.mjs @@ -0,0 +1,155 @@ +import {createHash} from 'node:crypto'; +import {once} from 'node:events'; +import {createServer} from 'node:http'; +import {gzipSync} from 'node:zlib'; + +function createSimpleTarArchive(fileName, fileContent, mode = 0o644) { + const contentBuffer = Buffer.from(fileContent); + + const header = Buffer.alloc(512); // TAR headers are 512 bytes + header.write(fileName); + header.write(`100${mode.toString(8)} `, 100, 7, `utf-8`); // File mode (octal) followed by a space + header.write(`0001750 `, 108, 8, `utf-8`); // Owner's numeric user ID (octal) followed by a space + header.write(`0001750 `, 116, 8, `utf-8`); // Group's numeric user ID (octal) followed by a space + header.write(`${contentBuffer.length.toString(8)} `, 124, 12, `utf-8`); // File size in bytes (octal) followed by a space + header.write(`${Math.floor(Date.now() / 1000).toString(8)} `, 136, 12, `utf-8`); // Last modification time in numeric Unix time format (octal) followed by a space + header.fill(` `, 148, 156); // Fill checksum area with spaces for calculation + header.write(`ustar `, 257, 8, `utf-8`); // UStar indicator + + // Calculate and write the checksum. Note: This is a simplified calculation not recommended for production + const checksum = header.reduce((sum, value) => sum + value, 0); + header.write(`${checksum.toString(8)}\0 `, 148, 8, `utf-8`); // Write checksum in octal followed by null and space + + + return Buffer.concat([ + header, + contentBuffer, + Buffer.alloc(512 - (contentBuffer.length % 512)), + ]); +} + +const mockPackageTarGz = gzipSync(Buffer.concat([ + createSimpleTarArchive(`package/bin/customPkgManager.js`, `#!/usr/bin/env node\nconsole.log("customPkgManager: Hello from custom registry");\n`, 0o755), + createSimpleTarArchive(`package/bin/pnpm.js`, `#!/usr/bin/env node\nconsole.log("pnpm: Hello from custom registry");\n`, 0o755), + createSimpleTarArchive(`package/bin/yarn.js`, `#!/usr/bin/env node\nconsole.log("yarn: Hello from custom registry");\n`, 0o755), + createSimpleTarArchive(`package/package.json`, JSON.stringify({bin: {yarn: `bin/yarn.js`, pnpm: `bin/pnpm.js`, customPkgManager: `bin/customPkgManager.js`}})), + Buffer.alloc(1024), +])); +const shasum = createHash(`sha1`).update(mockPackageTarGz).digest(`hex`); + + +const server = createServer((req, res) => { + const auth = req.headers.authorization; + if (!auth?.startsWith(`Bearer `) || Buffer.from(auth.slice(`Bearer `.length), `base64`).toString() !== `user:pass`) { + res.statusCode = 401; + res.end(`Unauthorized`); + return; + } + switch (req.url) { + case `/yarn`: { + res.end(JSON.stringify({"dist-tags": { + latest: `1.9998.9999`, + }, versions: {'1.9998.9999': { + dist: { + shasum, + size: mockPackageTarGz.length, + noattachment: false, + tarball: `${process.env.COREPACK_NPM_REGISTRY}/yarn.tgz`, + }, + }}})); + break; + } + + case `/pnpm`: { + res.end(JSON.stringify({"dist-tags": { + latest: `1.9998.9999`, + }, versions: {'1.9998.9999': { + dist: { + shasum, + size: mockPackageTarGz.length, + noattachment: false, + tarball: `${process.env.COREPACK_NPM_REGISTRY}/pnpm/-/pnpm-1.9998.9999.tgz`, + }, + }}})); + break; + } + + case `/@yarnpkg/cli-dist`: { + res.end(JSON.stringify({"dist-tags": { + latest: `5.9999.9999`, + }, versions: {'5.9999.9999': { + bin: { + yarn: `./bin/yarn.js`, + yarnpkg: `./bin/yarn.js`, + }, + dist: { + shasum, + size: mockPackageTarGz.length, + noattachment: false, + tarball: `${process.env.COREPACK_NPM_REGISTRY}/yarn.tgz`, + }, + }}})); + break; + } + + case `/customPkgManager`: { + res.end(JSON.stringify({"dist-tags": { + latest: `1.0.0`, + }, versions: {'1.0.0': { + bin: { + customPkgManager: `./bin/customPkgManager.js`, + }, + dist: { + shasum, + size: mockPackageTarGz.length, + noattachment: false, + tarball: `${process.env.COREPACK_NPM_REGISTRY}/customPkgManager/-/customPkgManager-1.0.0.tgz`, + }, + }}})); + break; + } + + case `/pnpm/-/pnpm-1.9998.9999.tgz`: + case `/yarn.tgz`: + case `/customPkgManager/-/customPkgManager-1.0.0.tgz`: + res.end(mockPackageTarGz); + break; + + default: + throw new Error(`unsupported request`, {cause: req.url}); + } +}).listen(0, `localhost`); + +await once(server, `listening`); + +const {address, port} = server.address(); +switch (process.env.AUTH_TYPE) { + case `COREPACK_NPM_REGISTRY`: + process.env.COREPACK_NPM_REGISTRY = `http://user:pass@${address.includes(`:`) ? `[${address}]` : address}:${port}`; + break; + + case `COREPACK_NPM_TOKEN`: + process.env.COREPACK_NPM_REGISTRY = `http://${address.includes(`:`) ? `[${address}]` : address}:${port}`; + process.env.COREPACK_NPM_TOKEN = Buffer.from(`user:pass`).toString(`base64`); + break; + + case `COREPACK_NPM_PASSWORD`: + process.env.COREPACK_NPM_REGISTRY = `http://${address.includes(`:`) ? `[${address}]` : address}:${port}`; + process.env.COREPACK_NPM_USER = `user`; + process.env.COREPACK_NPM_PASSWORD = `pass`; + break; + + default: throw new Error(`Invalid AUTH_TYPE in env`, {cause: process.env.AUTH_TYPE}); +} + +if (process.env.NOCK_ENV === `replay`) { + const originalFetch = globalThis.fetch; + globalThis.fetch = function fetch(i) { + if (!`${i}`.startsWith(`http://${address.includes(`:`) ? `[${address}]` : address}:${port}`)) + throw new Error; + + return Reflect.apply(originalFetch, this, arguments); + }; +} + +server.unref(); diff --git a/tests/_runCli.ts b/tests/_runCli.ts index 53077bac..e53adec4 100644 --- a/tests/_runCli.ts +++ b/tests/_runCli.ts @@ -1,12 +1,14 @@ import {PortablePath, npath} from '@yarnpkg/fslib'; import {spawn} from 'child_process'; +import * as path from 'path'; +import {pathToFileURL} from 'url'; -export async function runCli(cwd: PortablePath, argv: Array): Promise<{exitCode: number | null, stdout: string, stderr: string}> { +export async function runCli(cwd: PortablePath, argv: Array, withCustomRegistry?: boolean): Promise<{exitCode: number | null, stdout: string, stderr: string}> { const out: Array = []; const err: Array = []; return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [`--no-warnings`, `-r`, require.resolve(`./recordRequests.js`), require.resolve(`../dist/corepack.js`), ...argv], { + const child = spawn(process.execPath, [`--no-warnings`, ...(withCustomRegistry ? [`--import`, pathToFileURL(path.join(__dirname, `_registryServer.mjs`)) as any as string] : [`-r`, require.resolve(`./recordRequests.js`)]), require.resolve(`../dist/corepack.js`), ...argv], { cwd: npath.fromPortablePath(cwd), env: process.env, stdio: `pipe`, diff --git a/tests/main.test.ts b/tests/main.test.ts index 41616c7f..0d68ced9 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, it, expect} from '@jest/globals'; +import {beforeEach, describe, expect, it} from '@jest/globals'; import {Filename, ppath, xfs, npath, PortablePath} from '@yarnpkg/fslib'; import process from 'node:process'; @@ -804,3 +804,55 @@ it(`should download yarn berry from custom registry`, async () => { }); }); }); + +for (const authType of [`COREPACK_NPM_REGISTRY`, `COREPACK_NPM_TOKEN`, `COREPACK_NPM_PASSWORD`]) { + describe(`custom registry with auth ${authType}`, () => { + beforeEach(() => { + process.env.AUTH_TYPE = authType; // See `_registryServer.mjs` + }); + + it(`should download yarn classic`, async () => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`yarn@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + + it(`should download yarn berry`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@3.0.0`, + }); + + await expect(runCli(cwd, [`yarn@5.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `yarn: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + + it(`should download pnpm`, async () => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`pnpm@1.x`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + + it(`should download custom package manager`, async () => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`customPkgManager@https://registry.npmjs.org/customPkgManager/-/customPkgManager-1.0.0.tgz`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `customPkgManager: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + }); +}