Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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++
Expand Down
4 changes: 3 additions & 1 deletion workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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}`
Expand Down
64 changes: 64 additions & 0 deletions workspaces/arborist/lib/registry-package-name.js
Original file line number Diff line number Diff line change
@@ -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 }
115 changes: 115 additions & 0 deletions workspaces/arborist/test/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions workspaces/arborist/test/registry-package-name.js
Original file line number Diff line number Diff line change
@@ -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()
})
Loading