From 06ef77f9d249cb6c32d5f154c65307ee05fd6ed4 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Tue, 3 Nov 2026 14:17:25 -0800 Subject: [PATCH] fix(arborist): preserve alias registry credentials on v10 Narrow alias identity/authentication adaptation of npm/cli#9921 for release/v10. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ede510fc-429c-4202-aac7-66584aaee157 --- .../arborist/lib/arborist/isolated-reifier.js | 3 + workspaces/arborist/lib/arborist/reify.js | 4 +- .../arborist/lib/registry-package-name.js | 64 ++++++++++ workspaces/arborist/test/arborist/reify.js | 115 ++++++++++++++++++ .../arborist/test/registry-package-name.js | 103 ++++++++++++++++ 5 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 workspaces/arborist/lib/registry-package-name.js create mode 100644 workspaces/arborist/test/registry-package-name.js diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 2869abd37efc7..c5fe73d8265f8 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -4,6 +4,7 @@ const { join } = require('node:path') const { depth } = require('treeverse') const crypto = require('node:crypto') const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js') +const { carryRegistryPackageName } = require('../registry-package-name.js') // generate short hash key based on the dependency tree starting at this node const getKey = (startNode) => { @@ -51,6 +52,7 @@ module.exports = cls => class IsolatedReifier extends cls { resolved: node.resolved, root, }) + carryRegistryPackageName(node, newChild) // XXX top is from place-dep not lib/node.js newChild.top = { path: this.idealGraph.localPath } root.children.set(newChild.location, newChild) @@ -186,6 +188,7 @@ module.exports = cls => class IsolatedReifier extends cls { } async #assignCommonProperties (node, result, populateDeps = true) { + carryRegistryPackageName(node, result) result.root = this.idealGraph // XXX does anything need this? result.id = this.counter++ diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index fdf011ad03cab..1753b7f06776c 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -35,6 +35,7 @@ const promiseAllRejectLate = require('promise-all-reject-late') const { callLimit: promiseCallLimit } = require('promise-call-limit') const optionalSet = require('../optional-set.js') const calcDepFlags = require('../calc-dep-flags.js') +const { getRegistryPackageName } = require('../registry-package-name.js') const { saveTypeMap, hasSubKey } = require('../add-rm-pkg-deps.js') const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js') @@ -774,7 +775,8 @@ module.exports = cls => class Reifier extends cls { if (node.resolved) { const registryResolved = this.#registryResolved(node.resolved) if (registryResolved) { - res = `${node.name}@${registryResolved}` + const packageName = getRegistryPackageName(node) || node.name + res = `${packageName}@${registryResolved}` } } else if (node.package.name && node.version) { res = `${node.package.name}@${node.version}` diff --git a/workspaces/arborist/lib/registry-package-name.js b/workspaces/arborist/lib/registry-package-name.js new file mode 100644 index 0000000000000..636ee95fd23bc --- /dev/null +++ b/workspaces/arborist/lib/registry-package-name.js @@ -0,0 +1,64 @@ +const npa = require('npm-package-arg') + +const carriedNames = new WeakMap() + +// Scoped registry credentials must follow dependency specs, not package metadata. +const getRegistryPackageName = node => { + if (carriedNames.has(node)) { + return carriedNames.get(node) + } + if (!node.edgesIn || typeof node.edgesIn[Symbol.iterator] !== 'function') { + return null + } + + const names = new Set() + const peerNames = new Set() + const rootNames = new Set() + const aliases = new Set() + for (const edge of node.edgesIn) { + if (!edge.valid) { + continue + } + let spec + try { + // Match v10 Edge.satisfiedBy: overrides do not apply inside bundles or shrinkwraps. + const requested = node.hasShrinkwrap || node.inShrinkwrap || node.inBundle + ? edge.rawSpec : edge.spec + spec = npa.resolve(edge.name, requested) + } catch { + return null + } + if (!spec.registry) { + return null + } + const name = spec.type === 'alias' ? spec.subSpec.name : spec.name + if (!name) { + return null + } + if (spec.type === 'alias') { + aliases.add(name) + } + // Ordinary peers constrain the installed slot, not an alias's target. + if (edge.peer && spec.type !== 'alias') { + peerNames.add(name) + } else { + names.add(name) + if (edge.from?.isProjectRoot || edge.from?.isWorkspace) { + rootNames.add(name) + } + } + } + + const identities = rootNames.size ? rootNames : names.size ? names : peerNames + if (identities.size !== 1) { + return null + } + const name = identities.values().next().value + return [...aliases].every(alias => alias === name) ? name : null +} + +const carryRegistryPackageName = (from, to) => { + carriedNames.set(to, getRegistryPackageName(from)) +} + +module.exports = { carryRegistryPackageName, getRegistryPackageName } diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index 766434e73c4a9..5b60eead0c6cb 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -139,6 +139,121 @@ const newArb = (opt) => new Arborist({ const reify = (path, opt) => newArb({ path, ...(opt || {}) }).reify(opt) +t.test('registry aliases retain target scope during extraction', async t => { + for (const installStrategy of ['hoisted', 'linked']) { + for (const consumerType of ['none', 'dependency', 'peer', 'optional peer']) { + await t.test(`${installStrategy}: ${consumerType}`, async t => { + const scopedRegistry = 'http://scoped.registry.test/npm/' + const defaultRegistry = 'http://default.registry.test/' + const targetName = '@private/target' + const targetUrl = 'http://scoped.registry.test/tarballs/target-1.1.1.tgz' + const widgetUrl = `${defaultRegistry}widget/-/widget-1.1.1.tgz` + const pkg = { + name: 'project', + version: '1.0.0', + dependencies: { + slot: `npm:${targetName}@1.1.1`, + ...(consumerType === 'none' ? {} : { widget: '1.1.1' }), + }, + } + const path = t.testdir({ + 'package.json': JSON.stringify(pkg), + 'package-lock.json': JSON.stringify({ + name: pkg.name, + version: pkg.version, + lockfileVersion: 3, + packages: { + '': pkg, + 'node_modules/slot': { + name: '@untrusted/metadata', + version: '1.1.1', + resolved: targetUrl, + }, + ...(consumerType === 'none' ? {} : { + 'node_modules/widget': { + version: '1.1.1', + resolved: widgetUrl, + ...(consumerType === 'dependency' + ? { dependencies: { slot: '^1.0.0' } } + : { peerDependencies: { slot: '^1.0.0' } }), + ...(consumerType === 'optional peer' + ? { peerDependenciesMeta: { slot: { optional: true } } } + : {}), + }, + }), + }, + }), + }) + const tarball = fs.readFileSync(resolve(__dirname, + '../fixtures/registry-mocks/content/abbrev/-/abbrev-1.1.1.tgz')) + tnock(t, 'http://scoped.registry.test') + .get('/tarballs/target-1.1.1.tgz') + .matchHeader('authorization', 'Bearer scoped-test-token') + .reply(200, tarball) + if (consumerType !== 'none') { + tnock(t, defaultRegistry) + .get('/widget/-/widget-1.1.1.tgz') + .reply(200, tarball) + } + const arb = new Arborist({ + path, + cache: resolve(path, 'cache'), + registry: defaultRegistry, + '@private:registry': scopedRegistry, + '//scoped.registry.test/npm/:_authToken': 'scoped-test-token', + audit: false, + ignoreScripts: true, + installStrategy, + }) + await arb.reify() + t.equal( + JSON.parse(fs.readFileSync(resolve(path, 'node_modules/slot/package.json'))).name, + 'abbrev', + 'installs at the alias slot without trusting the tarball package name' + ) + }) + } + } + await t.test('global alias install', async t => { + const path = t.testdir() + const targetName = '@private/target' + const targetUrl = 'http://scoped.registry.test/tarballs/target-1.1.1.tgz' + tnock(t, 'http://scoped.registry.test') + .get('/npm/@private%2ftarget') + .matchHeader('authorization', 'Bearer scoped-test-token') + .reply(200, { + name: targetName, + 'dist-tags': { latest: '1.1.1' }, + versions: { + '1.1.1': { + name: targetName, + version: '1.1.1', + dist: { tarball: targetUrl }, + }, + }, + }) + .get('/tarballs/target-1.1.1.tgz') + .matchHeader('authorization', 'Bearer scoped-test-token') + .reply(200, fs.readFileSync(resolve(__dirname, + '../fixtures/registry-mocks/content/abbrev/-/abbrev-1.1.1.tgz'))) + await new Arborist({ + path, + cache: resolve(path, 'cache'), + global: true, + registry: 'http://default.registry.test/', + '@private:registry': 'http://scoped.registry.test/npm/', + '//scoped.registry.test/npm/:_authToken': 'scoped-test-token', + audit: false, + ignoreScripts: true, + }).reify({ add: [`slot@npm:${targetName}@1.1.1`] }) + t.equal( + JSON.parse(fs.readFileSync(resolve(path, 'node_modules/slot/package.json'))).name, + 'abbrev', + 'global extraction also follows the alias target scope' + ) + }) +}) + t.test('bundled file dep with same name as other dep', async t => { const tree = await printReified(fixture(t, 'conflict-bundle-file-dep')) t.matchSnapshot(tree) diff --git a/workspaces/arborist/test/registry-package-name.js b/workspaces/arborist/test/registry-package-name.js new file mode 100644 index 0000000000000..79a0786f1db2c --- /dev/null +++ b/workspaces/arborist/test/registry-package-name.js @@ -0,0 +1,103 @@ +const t = require('tap') +const Node = require('../lib/node.js') +const { + carryRegistryPackageName, + getRegistryPackageName, +} = require('../lib/registry-package-name.js') + +const edge = (name, spec, options = {}) => ({ name, spec, valid: true, ...options }) +const node = (...edgesIn) => ({ edgesIn: new Set(edgesIn) }) +const alias = () => edge('slot', 'npm:@private/target@1.1.1') + +t.test('derives registry identity from valid dependency specifications', t => { + t.equal(getRegistryPackageName(node(edge('abbrev', '^1.0.0'))), 'abbrev') + t.equal(getRegistryPackageName(node(alias())), '@private/target') + t.equal(getRegistryPackageName(node( + alias(), + edge('slot', 'npm:@other/target@1.1.1', { valid: false }) + )), '@private/target', 'invalid edges do not change the identity') + t.equal(getRegistryPackageName(node( + alias(), + edge('slot', 'npm:@other/target@1.1.1') + )), null, 'conflicting explicit aliases are ambiguous') + t.equal(getRegistryPackageName(node( + alias(), + edge('slot', '^1.0.0', { peer: true }) + )), '@private/target', 'ordinary peers constrain the alias slot') + t.equal(getRegistryPackageName(node( + edge('slot', '^1.0.0', { peer: true }), + { ...alias(), peer: true } + )), '@private/target', 'explicit peer aliases select the target') + t.equal(getRegistryPackageName(node( + edge('slot', '^1.0.0', { peer: true }), + edge('slot', '~1.1.0', { peer: true }) + )), 'slot', 'ordinary peer-only nodes still have an identity') + t.equal(getRegistryPackageName(node( + alias(), + edge('slot', '^1.0.0') + )), null, 'ordinary transitive selections alone are ambiguous') + for (const from of [{ isProjectRoot: true }, { isWorkspace: true }]) { + t.equal(getRegistryPackageName(node( + { ...alias(), from }, + edge('slot', '^1.0.0') + )), '@private/target', 'project and workspace selections take precedence') + t.equal(getRegistryPackageName(node( + edge('slot', '^1.0.0', { from }), + alias() + )), null, 'a transitive alias cannot replace a root selection') + } + for (const spec of ['https://example.test/pkg.tgz', 'file:../pkg', 'npm:']) { + t.equal(getRegistryPackageName(node(edge('pkg', spec))), null, spec) + } + t.equal(getRegistryPackageName(node(edge(undefined, '1.0.0'))), null) + t.equal(getRegistryPackageName({}), null) + t.equal(getRegistryPackageName({ edgesIn: {} }), null) + t.equal(getRegistryPackageName(node()), null) + t.end() +}) + +t.test('preserves trusted identity across linked transformations', t => { + const source = node(alias()) + const proxy = node() + const store = node() + carryRegistryPackageName(source, proxy) + carryRegistryPackageName(proxy, store) + t.equal(getRegistryPackageName(store), '@private/target') + carryRegistryPackageName(node(), source) + t.equal(getRegistryPackageName(source), null, 'carried ambiguity overrides synthetic edges') + t.end() +}) + +t.test('uses v10 override and bundled dependency semantics', t => { + const root = new Node({ + path: t.testdirName, + loadOverrides: true, + pkg: { + dependencies: { widget: '1.0.0' }, + overrides: { slot: 'npm:@private/target@1.1.1' }, + }, + }) + const consumer = new Node({ + name: 'widget', + parent: root, + pkg: { + name: 'widget', + version: '1.0.0', + dependencies: { slot: '^1.0.0' }, + }, + }) + const target = new Node({ + name: 'slot', + parent: consumer, + pkg: { name: '@untrusted/metadata', version: '1.1.1' }, + }) + t.ok(consumer.edgesOut.get('slot').valid) + t.equal(getRegistryPackageName(target), '@private/target', 'uses the effective override') + for (const flag of ['hasShrinkwrap', 'inShrinkwrap', 'inBundle']) { + t.equal(getRegistryPackageName({ + [flag]: true, + edgesIn: target.edgesIn, + }), 'slot', `${flag} uses the raw spec, as v10 Edge.satisfiedBy does`) + } + t.end() +})