@npmcli/arborist 9.5.0 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,15 @@ const Shrinkwrap = require('../shrinkwrap.js')
28
28
  const { defaultLockfileVersion } = Shrinkwrap
29
29
  const Node = require('../node.js')
30
30
  const Link = require('../link.js')
31
+
32
+ // Maps a parsed spec.type to the corresponding allow-* arborist option name.
33
+ // Hoisted to module scope so #checkAllow doesn't re-allocate it per call.
34
+ const ALLOW_OPTION_FOR_TYPE = {
35
+ git: 'allowGit',
36
+ remote: 'allowRemote',
37
+ file: 'allowFile',
38
+ directory: 'allowDirectory',
39
+ }
31
40
  const addRmPkgDeps = require('../add-rm-pkg-deps.js')
32
41
  const optionalSet = require('../optional-set.js')
33
42
  const { checkEngine, checkPlatform } = require('npm-install-checks')
@@ -649,6 +658,45 @@ module.exports = cls => class IdealTreeBuilder extends cls {
649
658
  return vuln.range
650
659
  }
651
660
 
661
+ // Enforces the allow-git / allow-file / allow-directory / allow-remote configs at the arborist resolution layer, before any branching into the symlink (Link) path or the manifest-fetch path.
662
+ // Pacote also enforces these inside FetcherBase.get() as defense-in-depth, but the symlink branch never reaches pacote, and the manifest cache here would bypass pacote on a cached hit.
663
+ // Throws the same { code: EALLOW${TYPE} } shape pacote uses, so callers and downstream consumers stay consistent.
664
+ #checkAllow (spec, edge) {
665
+ const optName = ALLOW_OPTION_FOR_TYPE[spec.type]
666
+ if (!optName) {
667
+ return
668
+ }
669
+ const allow = this.options[optName] ?? 'all'
670
+ if (allow === 'all') {
671
+ return
672
+ }
673
+ const isRoot = !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace)
674
+ if (allow !== 'none' && isRoot) {
675
+ return
676
+ }
677
+ throw Object.assign(
678
+ new Error(`Fetching${allow === 'root' ? ' non-root' : ''} packages of type "${spec.type}" have been disabled`),
679
+ {
680
+ code: `EALLOW${spec.type.toUpperCase()}`,
681
+ package: spec.toString(),
682
+ }
683
+ )
684
+ }
685
+
686
+ // Builds a Node representing a spec we failed to load (allow-* gate, network failure, ENOTARGET, etc.) and records it in #loadFailures so #pruneFailedOptional can later decide whether the failure is fatal or silently dropped for optional deps.
687
+ #failureNode (name, parent, error, edge) {
688
+ error.requiredBy = edge?.from?.location || '.'
689
+ const n = new Node({
690
+ name,
691
+ parent,
692
+ error,
693
+ installLinks: this.installLinks,
694
+ legacyPeerDeps: this.legacyPeerDeps,
695
+ })
696
+ this.#loadFailures.add(n)
697
+ return n
698
+ }
699
+
652
700
  #queueNamedUpdates () {
653
701
  // ignore top nodes, since they are not loaded the same way, and
654
702
  // probably have their own project associated with them.
@@ -1040,7 +1088,7 @@ This is a one-time fix-up, please be patient...
1040
1088
  // This can't be changed or removed till we figure out why
1041
1089
  // The test is named "tarball deps with transitive tarball deps"
1042
1090
  promises.push(() =>
1043
- this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent)
1091
+ this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent, e)
1044
1092
  .catch(() => null)
1045
1093
  )
1046
1094
  }
@@ -1231,12 +1279,14 @@ This is a one-time fix-up, please be patient...
1231
1279
  return problems
1232
1280
  }
1233
1281
 
1234
- async #fetchManifest (spec, parent) {
1282
+ async #fetchManifest (spec, parent, edge) {
1283
+ // Enforce allow-* gates before consulting the manifest cache so a cached entry from a different edge cannot bypass the policy.
1284
+ this.#checkAllow(spec, edge)
1235
1285
  const options = {
1236
1286
  ...this.options,
1237
1287
  avoid: this.#avoidRange(spec.name),
1238
1288
  fullMetadata: true,
1239
- _isRoot: parent?.isProjectRoot || parent?.isWorkspace,
1289
+ _isRoot: !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace),
1240
1290
  }
1241
1291
  // get the intended spec and stored metadata from yarn.lock file,
1242
1292
  // if available and valid.
@@ -1253,6 +1303,14 @@ This is a one-time fix-up, please be patient...
1253
1303
  }
1254
1304
 
1255
1305
  async #nodeFromSpec (name, spec, parent, edge) {
1306
+ // Enforce allow-git / allow-file / allow-directory / allow-remote before any branching, so the symlink (Link) path is enforced as well as the manifest-fetch path.
1307
+ // Route the failure through #loadFailures so optional-dep semantics apply (e.g. a transitive optionalDependencies entry that resolves to a disallowed git URL is silently dropped rather than failing the install).
1308
+ try {
1309
+ this.#checkAllow(spec, edge)
1310
+ } catch (error) {
1311
+ return this.#failureNode(name, parent, error, edge)
1312
+ }
1313
+
1256
1314
  // pacote will slap integrity on its options, so we have to clone the object so it doesn't get mutated.
1257
1315
  // Don't bother to load the manifest for link deps, because the target might be within another package that doesn't exist yet.
1258
1316
  const { installLinks, legacyPeerDeps } = this
@@ -1307,23 +1365,26 @@ This is a one-time fix-up, please be patient...
1307
1365
 
1308
1366
  // spec isn't a directory, and either isn't a workspace or the workspace we have
1309
1367
  // doesn't satisfy the edge. try to fetch a manifest and build a node from that.
1310
- return this.#fetchManifest(spec, parent)
1311
- .then(pkg => new Node({ name, pkg, parent, installLinks, legacyPeerDeps }), error => {
1312
- error.requiredBy = edge.from.location || '.'
1313
-
1314
- // failed to load the spec, either because of enotarget or
1315
- // fetch failure of some other sort. save it so we can verify
1316
- // later that it's optional; otherwise, the error is fatal.
1317
- const n = new Node({
1318
- name,
1319
- parent,
1320
- error,
1321
- installLinks,
1322
- legacyPeerDeps,
1323
- })
1324
- this.#loadFailures.add(n)
1325
- return n
1326
- })
1368
+ return this.#fetchManifest(spec, parent, edge)
1369
+ .then(
1370
+ pkg => {
1371
+ // When a proxy/upstream registry returns an incomplete manifest
1372
+ // (e.g. missing version field for platform-specific packages it
1373
+ // hasn't cached), treat it as a load failure so that optional deps
1374
+ // are properly pruned instead of written to the lockfile without
1375
+ // version metadata. Only apply to registry specs — file: deps
1376
+ // legitimately omit version.
1377
+ if (!pkg.version && spec.registry) {
1378
+ const error = Object.assign(
1379
+ new Error(`incomplete manifest for ${name}, missing version`),
1380
+ { code: 'EINCOMPLETEMANIFEST' }
1381
+ )
1382
+ return this.#failureNode(name, parent, error, edge)
1383
+ }
1384
+ return new Node({ name, pkg, parent, installLinks, legacyPeerDeps })
1385
+ },
1386
+ error => this.#failureNode(name, parent, error, edge)
1387
+ )
1327
1388
  }
1328
1389
 
1329
1390
  // load all peer deps and meta-peer deps into the node's parent
@@ -97,7 +97,9 @@ module.exports = cls => class IsolatedReifier extends cls {
97
97
  }
98
98
  this.counter = 0
99
99
 
100
- this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.#workspaceProxy(w)))
100
+ // Skip extraneous fsChildren: workspaces removed from the root manifest can linger in fsChildren via the lockfile, and re-materializing them here would re-create a directory the user just deleted.
101
+ const fsChildren = Array.from(idealTree.fsChildren.values()).filter(w => !w.extraneous)
102
+ this.idealGraph.workspaces = await Promise.all(fsChildren.map(w => this.#workspaceProxy(w)))
101
103
  const processed = new Set()
102
104
  const queue = [idealTree, ...idealTree.fsChildren]
103
105
  while (queue.length !== 0) {
@@ -4,6 +4,7 @@ const hgi = require('hosted-git-info')
4
4
  const npa = require('npm-package-arg')
5
5
  const packageContents = require('@npmcli/installed-package-contents')
6
6
  const pacote = require('pacote')
7
+ const { pickRegistry } = require('npm-registry-fetch')
7
8
  const promiseAllRejectLate = require('promise-all-reject-late')
8
9
  const runScript = require('@npmcli/run-script')
9
10
  const { callLimit: promiseCallLimit } = require('promise-call-limit')
@@ -741,7 +742,14 @@ module.exports = cls => class Reifier extends cls {
741
742
  ...this.options,
742
743
  resolved: node.resolved,
743
744
  integrity: node.integrity,
744
- _isRoot: node.parent?.isProjectRoot || node.parent?.isWorkspace,
745
+ // A node counts as "root" for allow-* enforcement if it satisfies at least one valid dependency edge declared by the project root or a workspace.
746
+ // node.parent is unsafe here: after hoisting, transitive packages can have the project root as their tree parent.
747
+ _isRoot: [...node.edgesIn].some(e =>
748
+ e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
749
+ ),
750
+ // pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
751
+ // Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball.
752
+ ...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}),
745
753
  })
746
754
  // store nodes don't use Node class so node.package doesn't get updated
747
755
  if (node.isInStore) {
@@ -865,6 +873,24 @@ module.exports = cls => class Reifier extends cls {
865
873
  return wrapper
866
874
  }
867
875
 
876
+ // When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
877
+ // 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).
878
+ // Returns true only when we are confident this is a registry-mediated install: the node's inbound edges must all be registry-typed (no exotic spec smuggled the URL in) AND the resolved URL's host must match the registry npm-registry-fetch selected for this spec, so a tampered lockfile pointing at an attacker host still hits the gate.
879
+ #isRegistryResolvedTarball (node) {
880
+ if (!node.resolved || !node.isRegistryDependency) {
881
+ return false
882
+ }
883
+ try {
884
+ // Hostnames are case-insensitive; lowercase both sides for safety even though WHATWG URL already normalizes.
885
+ const resolvedHost = new URL(node.resolved).hostname.toLowerCase()
886
+ // pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
887
+ const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname.toLowerCase()
888
+ return resolvedHost === registryHost
889
+ } catch {
890
+ return false
891
+ }
892
+ }
893
+
868
894
  #registryResolved (resolved) {
869
895
  // the default registry url is a magic value meaning "the currently
870
896
  // configured registry".
package/lib/link.js CHANGED
@@ -109,12 +109,24 @@ class Link extends Node {
109
109
  // so this is a no-op
110
110
  [_loadDeps] () {}
111
111
 
112
- // When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut.
113
- // Without this, overrides stop at the Link and never reach the target's dependency edges.
112
+ // When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut — but only when the OverrideSet has at least one rule that names a dep the target actually depends on.
113
+ // Without this scope, the link forwards a generic ancestor OverrideSet that has no real effect on the target's edges, but still flips the target to "has overrides", which changes downstream `canReplaceWith` / placement decisions and causes `npm ci` to re-resolve lockfile-pinned edges from the registry.
114
+ // See npm/cli#9357.
114
115
  recalculateOutEdgesOverrides () {
115
- if (this.target) {
116
- this.target.updateOverridesEdgeInAdded(this.overrides)
116
+ if (!this.target || !this.overrides) {
117
+ return
118
+ }
119
+ let hasMatchingRule = false
120
+ for (const rule of this.overrides.ruleset.values()) {
121
+ if (this.target.edgesOut.has(rule.name)) {
122
+ hasMatchingRule = true
123
+ break
124
+ }
125
+ }
126
+ if (!hasMatchingRule) {
127
+ return
117
128
  }
129
+ this.target.updateOverridesEdgeInAdded(this.overrides)
118
130
  }
119
131
 
120
132
  // links can't have children, only their targets can
package/lib/shrinkwrap.js CHANGED
@@ -929,10 +929,24 @@ class Shrinkwrap {
929
929
  continue
930
930
  }
931
931
  const loc = relpath(this.path, node.path)
932
- this.data.packages[loc] = Shrinkwrap.metaFromNode(
932
+ // Drop lockfile entries for extraneous nodes outside node_modules. These are stale workspace entries: the workspace was removed from package.json or its directory was deleted, so it should not be tracked in package-lock.json.
933
+ if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules') {
934
+ continue
935
+ }
936
+ const meta = Shrinkwrap.metaFromNode(
933
937
  node,
934
938
  this.path,
935
939
  this.resolveOptions)
940
+ // Skip inert nodes — these are optional deps that failed to load
941
+ // (e.g. 404 from a proxy registry that hasn't cached the package,
942
+ // or incomplete manifest missing version field).
943
+ // #pruneFailedOptional marks them inert so they won't be reified;
944
+ // writing them to the lockfile produces invalid entries like
945
+ // {"optional": true} that cause "Invalid Version:" errors.
946
+ if (node.inert && !node.package.version) {
947
+ continue
948
+ }
949
+ this.data.packages[loc] = meta
936
950
  }
937
951
  } else if (this.#awaitingUpdate.size > 0) {
938
952
  for (const loc of this.#awaitingUpdate.keys()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@gar/promise-retry": "^1.0.0",