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/build-ideal-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const PackageJson = require('@npmcli/package-json')
const npa = require('npm-package-arg')
const pacote = require('pacote')
const cacache = require('cacache')
const { isRegistryResolvedTarball } = require('../registry-resolved.js')
const { callLimit: promiseCallLimit } = require('promise-call-limit')
const realpath = require('../../lib/realpath.js')
const { resolve, dirname, sep } = require('node:path')
Expand Down Expand Up @@ -1028,6 +1029,8 @@ This is a one-time fix-up, please be patient...
Arborist,
resolved: node.resolved,
integrity: node.integrity,
...(isRegistryResolvedTarball(node, this.options) ?
{ allowRemote: 'all' } : {}),
})

await new Arborist({ ...this.options, path })
Expand Down
90 changes: 9 additions & 81 deletions workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// mixin implementing the reify method
const PackageJson = require('@npmcli/package-json')
const hgi = require('hosted-git-info')
const npa = require('npm-package-arg')
const packageContents = require('@npmcli/installed-package-contents')
const pacote = require('pacote')
const { pickRegistry } = require('npm-registry-fetch')
const promiseAllRejectLate = require('promise-all-reject-late')
const runScript = require('@npmcli/run-script')
const { callLimit: promiseCallLimit } = require('promise-call-limit')
Expand All @@ -18,6 +16,10 @@ const { subset, intersects } = require('semver')
const { walkUp } = require('walk-up-path')

const AuditReport = require('../audit-report.js')
const {
isRegistryResolvedTarball,
registryResolved,
} = require('../registry-resolved.js')
const Diff = require('../diff.js')
const calcDepFlags = require('../calc-dep-flags.js')
const debug = require('../debug.js')
Expand Down Expand Up @@ -706,9 +708,9 @@ module.exports = cls => class Reifier extends cls {
// entirely, since we can't possibly reify it.
let res = null
if (node.resolved) {
const registryResolved = this.#registryResolved(node.resolved)
if (registryResolved) {
res = `${node.name}@${registryResolved}`
const resolved = registryResolved(node.resolved, this.options)
if (resolved) {
res = `${node.name}@${resolved}`
}
} else if (node.package.name && node.version) {
res = `${node.package.name}@${node.version}`
Expand Down Expand Up @@ -748,8 +750,8 @@ module.exports = cls => class Reifier extends cls {
e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
),
// pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
// Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball.
...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}),
// Override only when we can prove the URL is registry-mediated; see isRegistryResolvedTarball.
...(isRegistryResolvedTarball(node, this.options) ? { allowRemote: 'all' } : {}),
})
// store nodes don't use Node class so node.package doesn't get updated
if (node.isInStore) {
Expand Down Expand Up @@ -982,80 +984,6 @@ module.exports = cls => class Reifier extends cls {
return realpathSync(child.path) !== realpathSync(child.realpath)
}

// When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
// pacote re-parses that with npa and gets spec.type === 'remote', so without an override the allow-remote gate would fire on every registry tarball (both =none and =root mis-fire).
// Returns true only when we are confident this is a registry-mediated install.
#isRegistryResolvedTarball (node) {
if (!node.resolved || !node.isRegistryDependency) {
return false
}
try {
// Match the effective fetch URL, not the raw lockfile value.
// #registryResolved applies replace-registry-host, rewriting a public-registry pin to the configured proxy/mirror so it matches.
const resolvedURL = new URL(this.#registryResolved(node.resolved))
// pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
const registry = new URL(pickRegistry(npa(node.name), this.options))
const registryPath = registry.pathname.replace(/\/?$/, '/')
return resolvedURL.origin === registry.origin &&
(registryPath === '/' || resolvedURL.pathname.startsWith(registryPath))
} catch {
return false
}
}

#registryResolved (resolved) {
// the default registry url is a magic value meaning "the currently
// configured registry".
// `resolved` must never be falsey.
//
// XXX: use a magic string that isn't also a valid value, like
// ${REGISTRY} or something. This has to be threaded through the
// Shrinkwrap and Node classes carefully, so for now, just treat
// the default reg as the magical animal that it has been.
try {
const resolvedURL = hgi.parseUrl(resolved)
const registryURL = new URL(this.registry)
const registryPath = registryURL.pathname.replace(/\/$/, '')

let matchURL = null
try {
matchURL = new URL(this.options.replaceRegistryHost)
} catch {
// keep matchURL null
}

const matchHost = matchURL?.hostname ?? this.options.replaceRegistryHost
const matchPath = matchURL?.pathname.replace(/\/$/, '') ?? null
const hasPathPrefix = (pathname, prefix) =>
pathname === prefix || pathname.startsWith(`${prefix}/`)

const hostMatches = this.options.replaceRegistryHost === 'always' || matchHost === resolvedURL.hostname
const pathMatches = !matchPath || hasPathPrefix(resolvedURL.pathname, matchPath)

if (!hostMatches || !pathMatches) {
return resolved
}

resolvedURL.protocol = registryURL.protocol
resolvedURL.hostname = registryURL.hostname
resolvedURL.port = registryURL.port

if (matchPath) {
// full-URL prefix: swap old path prefix for the registry path
resolvedURL.pathname = registryPath + resolvedURL.pathname.slice(matchPath.length)
} else if (registryPath && !hasPathPrefix(resolvedURL.pathname, registryPath)) {
// host-only: prepend registry path if not already present
resolvedURL.pathname = registryPath + resolvedURL.pathname
}

return resolvedURL.toString()
} catch {
// if we could not parse the url at all then returning nothing
// here means it will get removed from the tree in the next step
return undefined
}
}

// bundles are *sort of* like shrinkwraps, in that the branch is defined
// by the contents of the package. however, in their case, rather than
// shipping a virtual tree that must be reified, they ship an entire
Expand Down
71 changes: 71 additions & 0 deletions workspaces/arborist/lib/registry-resolved.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const hgi = require('hosted-git-info')
const npa = require('npm-package-arg')
const { pickRegistry } = require('npm-registry-fetch')

// Rewrite lockfile tarball URLs according to replace-registry-host.
const registryResolved = (resolved, options) => {
try {
const resolvedURL = hgi.parseUrl(resolved)
const registryURL = new URL(options.registry)
const registryPath = registryURL.pathname.replace(/\/$/, '')

let matchURL = null
try {
matchURL = new URL(options.replaceRegistryHost)
} catch {
// keep matchURL null
}

const matchHost = matchURL?.hostname ?? options.replaceRegistryHost
const matchPath = matchURL?.pathname.replace(/\/$/, '') ?? null
const hasPathPrefix = (pathname, prefix) =>
pathname === prefix || pathname.startsWith(`${prefix}/`)

const hostMatches = options.replaceRegistryHost === 'always' ||
matchHost === resolvedURL.hostname
const pathMatches = !matchPath ||
hasPathPrefix(resolvedURL.pathname, matchPath)

if (!hostMatches || !pathMatches) {
return resolved
}

resolvedURL.protocol = registryURL.protocol
resolvedURL.hostname = registryURL.hostname
resolvedURL.port = registryURL.port

if (matchPath) {
resolvedURL.pathname = registryPath +
resolvedURL.pathname.slice(matchPath.length)
} else if (registryPath &&
!hasPathPrefix(resolvedURL.pathname, registryPath)) {
resolvedURL.pathname = registryPath + resolvedURL.pathname
}

return resolvedURL.toString()
} catch {
return undefined
}
}

// Only bypass allow-remote when the effective URL is inside the registry path.
const isRegistryResolvedTarball = (node, options) => {
if (!node.resolved || !node.isRegistryDependency) {
return false
}
try {
const resolvedURL = new URL(registryResolved(node.resolved, options))
const registry = new URL(pickRegistry(npa(node.name), options))
const registryPath = registry.pathname.replace(/\/?$/, '/')
return resolvedURL.origin === registry.origin &&
(registryPath === '/' ||
resolvedURL.pathname.startsWith(registryPath))
} catch {
return false
}
}

module.exports = {
isRegistryResolvedTarball,
registryResolved,
}
10 changes: 10 additions & 0 deletions workspaces/arborist/test/arborist/build-ideal-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,16 @@ t.test('bundle deps example 1, complete:true', async t => {
}), 'no missing deps, because complete: true, add dep, save bundled')
})

t.test('complete build allows registry tarballs with allowRemote=none', async t => {
const path = resolve(fixtures, 'testing-bundledeps-empty')
createRegistry(t, true)

await t.resolves(buildIdeal(path, {
complete: true,
allowRemote: 'none',
}))
})

t.test('bundle deps example 2', async t => {
// bundled deps at the root level are NOT ignored when building ideal trees
const path = resolve(fixtures, 'testing-bundledeps-2')
Expand Down