diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 6d9ecd1ea555d..8499912318884 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -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') @@ -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 }) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index b099d4d72c6a4..d3f027f967fa9 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -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') @@ -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') @@ -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}` @@ -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) { @@ -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 diff --git a/workspaces/arborist/lib/registry-resolved.js b/workspaces/arborist/lib/registry-resolved.js new file mode 100644 index 0000000000000..386e52babb073 --- /dev/null +++ b/workspaces/arborist/lib/registry-resolved.js @@ -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, +} diff --git a/workspaces/arborist/test/arborist/build-ideal-tree.js b/workspaces/arborist/test/arborist/build-ideal-tree.js index 6b1da789deddf..ca13f089424b5 100644 --- a/workspaces/arborist/test/arborist/build-ideal-tree.js +++ b/workspaces/arborist/test/arborist/build-ideal-tree.js @@ -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')