@npmcli/arborist 2.7.0 → 2.8.2

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.
@@ -188,8 +188,10 @@ module.exports = cls => class ActualLoader extends cls {
188
188
  const tree = this[_actualTree]
189
189
  const actualRoot = tree.isLink ? tree.target : tree
190
190
  const { dependencies = {} } = actualRoot.package
191
- for (const name of actualRoot.children.keys())
192
- dependencies[name] = dependencies[name] || '*'
191
+ for (const [name, kid] of actualRoot.children.entries()) {
192
+ const def = kid.isLink ? `file:${kid.realpath}` : '*'
193
+ dependencies[name] = dependencies[name] || def
194
+ }
193
195
  actualRoot.package = { ...actualRoot.package, dependencies }
194
196
  }
195
197
  return this[_actualTree]
@@ -5,11 +5,14 @@ const pacote = require('pacote')
5
5
  const AuditReport = require('../audit-report.js')
6
6
  const {subset, intersects} = require('semver')
7
7
  const npa = require('npm-package-arg')
8
+ const debug = require('../debug.js')
9
+ const walkUp = require('walk-up-path')
8
10
 
9
11
  const {dirname, resolve, relative} = require('path')
10
12
  const {depth: dfwalk} = require('treeverse')
11
13
  const fs = require('fs')
12
14
  const {promisify} = require('util')
15
+ const lstat = promisify(fs.lstat)
13
16
  const symlink = promisify(fs.symlink)
14
17
  const mkdirp = require('mkdirp-infer-owner')
15
18
  const justMkdirp = require('mkdirp')
@@ -18,6 +21,7 @@ const rimraf = promisify(require('rimraf'))
18
21
  const PackageJson = require('@npmcli/package-json')
19
22
  const packageContents = require('@npmcli/installed-package-contents')
20
23
  const { checkEngine, checkPlatform } = require('npm-install-checks')
24
+ const _force = Symbol.for('force')
21
25
 
22
26
  const treeCheck = require('../tree-check.js')
23
27
  const relpath = require('../relpath.js')
@@ -50,6 +54,7 @@ const _createSparseTree = Symbol.for('createSparseTree')
50
54
  const _loadShrinkwrapsAndUpdateTrees = Symbol.for('loadShrinkwrapsAndUpdateTrees')
51
55
  const _shrinkwrapInflated = Symbol('shrinkwrapInflated')
52
56
  const _bundleUnpacked = Symbol('bundleUnpacked')
57
+ const _bundleMissing = Symbol('bundleMissing')
53
58
  const _reifyNode = Symbol.for('reifyNode')
54
59
  const _extractOrLink = Symbol('extractOrLink')
55
60
  // defined by rebuild mixin
@@ -74,8 +79,10 @@ const _copyIdealToActual = Symbol('copyIdealToActual')
74
79
  const _addOmitsToTrashList = Symbol('addOmitsToTrashList')
75
80
  const _packageLockOnly = Symbol('packageLockOnly')
76
81
  const _dryRun = Symbol('dryRun')
82
+ const _validateNodeModules = Symbol('validateNodeModules')
83
+ const _nmValidated = Symbol('nmValidated')
77
84
  const _validatePath = Symbol('validatePath')
78
- const _reifyPackages = Symbol('reifyPackages')
85
+ const _reifyPackages = Symbol.for('reifyPackages')
79
86
 
80
87
  const _omitDev = Symbol('omitDev')
81
88
  const _omitOptional = Symbol('omitOptional')
@@ -83,8 +90,9 @@ const _omitPeer = Symbol('omitPeer')
83
90
 
84
91
  const _global = Symbol.for('global')
85
92
 
93
+ const _pruneBundledMetadeps = Symbol('pruneBundledMetadeps')
94
+
86
95
  // defined by Ideal mixin
87
- const _pruneBundledMetadeps = Symbol.for('pruneBundledMetadeps')
88
96
  const _resolvedAdd = Symbol.for('resolvedAdd')
89
97
  const _usePackageLock = Symbol.for('usePackageLock')
90
98
  const _formatPackageLock = Symbol.for('formatPackageLock')
@@ -112,6 +120,11 @@ module.exports = cls => class Reifier extends cls {
112
120
  this[_sparseTreeDirs] = new Set()
113
121
  this[_sparseTreeRoots] = new Set()
114
122
  this[_trashList] = new Set()
123
+ // the nodes we unpack to read their bundles
124
+ this[_bundleUnpacked] = new Set()
125
+ // child nodes we'd EXPECT to be included in a bundle, but aren't
126
+ this[_bundleMissing] = new Set()
127
+ this[_nmValidated] = new Set()
115
128
  }
116
129
 
117
130
  // public method
@@ -152,6 +165,9 @@ module.exports = cls => class Reifier extends cls {
152
165
  // recursively, because it can have other side effects to do that
153
166
  // in a project directory. We just want to make it if it's missing.
154
167
  await justMkdirp(resolve(this.path))
168
+
169
+ // do not allow the top-level node_modules to be a symlink
170
+ await this[_validateNodeModules](resolve(this.path, 'node_modules'))
155
171
  }
156
172
 
157
173
  async [_reifyPackages] () {
@@ -321,12 +337,13 @@ module.exports = cls => class Reifier extends cls {
321
337
  ideal: this.idealTree,
322
338
  })
323
339
 
324
- for (const node of this.diff.removed) {
325
- // a node in a dep bundle will only be removed if its bundling dep
326
- // is removed as well. in which case, we don't have to delete it!
327
- if (!node.inDepBundle)
328
- this[_addNodeToTrashList](node)
329
- }
340
+ // we don't have to add 'removed' folders to the trashlist, because
341
+ // they'll be moved aside to a retirement folder, and then the retired
342
+ // folder will be deleted at the end. This is important when we have
343
+ // a folder like FOO being "removed" in favor of a folder like "foo",
344
+ // because if we remove node_modules/FOO on case-insensitive systems,
345
+ // it will remove the dep that we *want* at node_modules/foo.
346
+
330
347
  process.emit('timeEnd', 'reify:diffTrees')
331
348
  }
332
349
 
@@ -334,7 +351,7 @@ module.exports = cls => class Reifier extends cls {
334
351
  // removed later on in the process. optionally, also mark them
335
352
  // as a retired paths, so that we move them out of the way and
336
353
  // replace them when rolling back on failure.
337
- [_addNodeToTrashList] (node, retire) {
354
+ [_addNodeToTrashList] (node, retire = false) {
338
355
  const paths = [node.path, ...node.binPaths]
339
356
  const moves = this[_retiredPaths]
340
357
  this.log.silly('reify', 'mark', retire ? 'retired' : 'deleted', paths)
@@ -422,19 +439,40 @@ module.exports = cls => class Reifier extends cls {
422
439
  process.emit('time', 'reify:createSparse')
423
440
  // if we call this fn again, we look for the previous list
424
441
  // so that we can avoid making the same directory multiple times
425
- const dirs = this.diff.leaves
442
+ const leaves = this.diff.leaves
426
443
  .filter(diff => {
427
444
  return (diff.action === 'ADD' || diff.action === 'CHANGE') &&
428
445
  !this[_sparseTreeDirs].has(diff.ideal.path) &&
429
446
  !diff.ideal.isLink
430
447
  })
431
- .map(diff => diff.ideal.path)
432
-
433
- return promiseAllRejectLate(dirs.map(d => mkdirp(d)))
434
- .then(made => {
435
- made.forEach(made => this[_sparseTreeRoots].add(made))
436
- dirs.forEach(dir => this[_sparseTreeDirs].add(dir))
437
- })
448
+ .map(diff => diff.ideal)
449
+
450
+ // we check this in parallel, so guard against multiple attempts to
451
+ // retire the same path at the same time.
452
+ const dirsChecked = new Set()
453
+ return promiseAllRejectLate(leaves.map(async node => {
454
+ for (const d of walkUp(node.path)) {
455
+ if (d === node.top.path)
456
+ break
457
+ if (dirsChecked.has(d))
458
+ continue
459
+ dirsChecked.add(d)
460
+ const st = await lstat(d).catch(er => null)
461
+ // this can happen if we have a link to a package with a name
462
+ // that the filesystem treats as if it is the same thing.
463
+ // would be nice to have conditional istanbul ignores here...
464
+ /* istanbul ignore next - defense in depth */
465
+ if (st && !st.isDirectory()) {
466
+ const retired = retirePath(d)
467
+ this[_retiredPaths][d] = retired
468
+ this[_trashList].add(retired)
469
+ await this[_renamePath](d, retired)
470
+ }
471
+ }
472
+ const made = await mkdirp(node.path)
473
+ this[_sparseTreeDirs].add(node.path)
474
+ this[_sparseTreeRoots].add(made)
475
+ }))
438
476
  .then(() => process.emit('timeEnd', 'reify:createSparse'))
439
477
  }
440
478
 
@@ -529,7 +567,20 @@ module.exports = cls => class Reifier extends cls {
529
567
  })
530
568
  }
531
569
 
532
- [_extractOrLink] (node) {
570
+ // do not allow node_modules to be a symlink
571
+ async [_validateNodeModules] (nm) {
572
+ if (this[_force] || this[_nmValidated].has(nm))
573
+ return
574
+ const st = await lstat(nm).catch(() => null)
575
+ if (!st || st.isDirectory()) {
576
+ this[_nmValidated].add(nm)
577
+ return
578
+ }
579
+ this.log.warn('reify', 'Removing non-directory', nm)
580
+ await rimraf(nm)
581
+ }
582
+
583
+ async [_extractOrLink] (node) {
533
584
  // in normal cases, node.resolved should *always* be set by now.
534
585
  // however, it is possible when a lockfile is damaged, or very old,
535
586
  // or in some other race condition bugs in npm v6, that a previously
@@ -556,13 +607,29 @@ module.exports = cls => class Reifier extends cls {
556
607
  return
557
608
  }
558
609
 
559
- return node.isLink
560
- ? rimraf(node.path).then(() => this[_symlink](node))
561
- : pacote.extract(res, node.path, {
610
+ const nm = resolve(node.parent.path, 'node_modules')
611
+ await this[_validateNodeModules](nm)
612
+
613
+ if (node.isLink) {
614
+ await rimraf(node.path)
615
+ await this[_symlink](node)
616
+ } else {
617
+ await debug(async () => {
618
+ const st = await lstat(node.path).catch(e => null)
619
+ if (st && !st.isDirectory()) {
620
+ debug.log('unpacking into a non-directory', node)
621
+ throw Object.assign(new Error('ENOTDIR: not a directory'), {
622
+ code: 'ENOTDIR',
623
+ path: node.path,
624
+ })
625
+ }
626
+ })
627
+ await pacote.extract(res, node.path, {
562
628
  ...this.options,
563
629
  resolved: node.resolved,
564
630
  integrity: node.integrity,
565
631
  })
632
+ }
566
633
  }
567
634
 
568
635
  async [_symlink] (node) {
@@ -610,10 +677,9 @@ module.exports = cls => class Reifier extends cls {
610
677
  [_loadBundlesAndUpdateTrees] (
611
678
  depth = 0, bundlesByDepth = this[_getBundlesByDepth]()
612
679
  ) {
613
- if (depth === 0) {
614
- this[_bundleUnpacked] = new Set()
680
+ if (depth === 0)
615
681
  process.emit('time', 'reify:loadBundles')
616
- }
682
+
617
683
  const maxBundleDepth = bundlesByDepth.get('maxBundleDepth')
618
684
  if (depth > maxBundleDepth) {
619
685
  // if we did something, then prune the tree and update the diffs
@@ -642,14 +708,30 @@ module.exports = cls => class Reifier extends cls {
642
708
  }))
643
709
  // then load their unpacked children and move into the ideal tree
644
710
  .then(nodes =>
645
- promiseAllRejectLate(nodes.map(node => new this.constructor({
646
- ...this.options,
647
- path: node.path,
648
- }).loadActual({
649
- root: node,
650
- // don't transplant any sparse folders we created
651
- transplantFilter: node => node.package._id,
652
- }))))
711
+ promiseAllRejectLate(nodes.map(async node => {
712
+ const arb = new this.constructor({
713
+ ...this.options,
714
+ path: node.path,
715
+ })
716
+ const notTransplanted = new Set(node.children.keys())
717
+ await arb.loadActual({
718
+ root: node,
719
+ // don't transplant any sparse folders we created
720
+ // loadActual will set node.package to {} for empty directories
721
+ // if by chance there are some empty folders in the node_modules
722
+ // tree for some other reason, then ok, ignore those too.
723
+ transplantFilter: node => {
724
+ if (node.package._id) {
725
+ // it's actually in the bundle if it gets transplanted
726
+ notTransplanted.delete(node.name)
727
+ return true
728
+ } else
729
+ return false
730
+ },
731
+ })
732
+ for (const name of notTransplanted)
733
+ this[_bundleMissing].add(node.children.get(name))
734
+ })))
653
735
  // move onto the next level of bundled items
654
736
  .then(() => this[_loadBundlesAndUpdateTrees](depth + 1, bundlesByDepth))
655
737
  }
@@ -685,6 +767,27 @@ module.exports = cls => class Reifier extends cls {
685
767
  // https://github.com/npm/cli/issues/1597#issuecomment-667639545
686
768
  [_pruneBundledMetadeps] (bundlesByDepth) {
687
769
  const bundleShadowed = new Set()
770
+
771
+ // Example dep graph:
772
+ // root -> (a, c)
773
+ // a -> BUNDLE(b)
774
+ // b -> c
775
+ // c -> b
776
+ //
777
+ // package tree:
778
+ // root
779
+ // +-- a
780
+ // | +-- b(1)
781
+ // | +-- c(1)
782
+ // +-- b(2)
783
+ // +-- c(2)
784
+ // 1. mark everything that's shadowed by anything in the bundle. This
785
+ // marks b(2) and c(2).
786
+ // 2. anything with edgesIn from outside the set, mark not-extraneous,
787
+ // remove from set. This unmarks c(2).
788
+ // 3. continue until no change
789
+ // 4. remove everything in the set from the tree. b(2) is pruned
790
+
688
791
  // create the list of nodes shadowed by children of bundlers
689
792
  for (const bundles of bundlesByDepth.values()) {
690
793
  // skip the 'maxBundleDepth' item
@@ -700,36 +803,50 @@ module.exports = cls => class Reifier extends cls {
700
803
  }
701
804
  }
702
805
  }
703
- let changed = true
704
- while (changed) {
705
- changed = false
706
- for (const shadow of bundleShadowed) {
707
- if (!shadow.extraneous) {
708
- bundleShadowed.delete(shadow)
709
- continue
806
+
807
+ // lib -> (a@1.x) BUNDLE(a@1.2.3 (b@1.2.3))
808
+ // a@1.2.3 -> (b@1.2.3)
809
+ // a@1.3.0 -> (b@2)
810
+ // b@1.2.3 -> ()
811
+ // b@2 -> (c@2)
812
+ //
813
+ // root
814
+ // +-- lib
815
+ // | +-- a@1.2.3
816
+ // | +-- b@1.2.3
817
+ // +-- b@2 <-- shadowed, now extraneous
818
+ // +-- c@2 <-- also shadowed, because only dependent is shadowed
819
+ for (const shadow of bundleShadowed) {
820
+ for (const shadDep of shadow.edgesOut.values()) {
821
+ /* istanbul ignore else - pretty unusual situation, just being
822
+ * defensive here. Would mean that a bundled dep has a dependency
823
+ * that is unmet. which, weird, but if you bundle it, we take
824
+ * whatever you put there and assume the publisher knows best. */
825
+ if (shadDep.to) {
826
+ bundleShadowed.add(shadDep.to)
827
+ shadDep.to.extraneous = true
710
828
  }
829
+ }
830
+ }
711
831
 
832
+ let changed
833
+ do {
834
+ changed = false
835
+ for (const shadow of bundleShadowed) {
712
836
  for (const edge of shadow.edgesIn) {
713
- if (!edge.from.extraneous) {
837
+ if (!bundleShadowed.has(edge.from)) {
714
838
  shadow.extraneous = false
715
839
  bundleShadowed.delete(shadow)
716
840
  changed = true
717
- } else {
718
- for (const shadDep of shadow.edgesOut.values()) {
719
- /* istanbul ignore else - pretty unusual situation, just being
720
- * defensive here. Would mean that a bundled dep has a dependency
721
- * that is unmet. which, weird, but if you bundle it, we take
722
- * whatever you put there and assume the publisher knows best. */
723
- if (shadDep.to)
724
- bundleShadowed.add(shadDep.to)
725
- }
841
+ break
726
842
  }
727
843
  }
728
844
  }
729
- }
845
+ } while (changed)
846
+
730
847
  for (const shadow of bundleShadowed) {
731
- shadow.parent = null
732
848
  this[_addNodeToTrashList](shadow)
849
+ shadow.root = null
733
850
  }
734
851
  }
735
852
 
@@ -780,6 +897,7 @@ module.exports = cls => class Reifier extends cls {
780
897
  const node = diff.ideal
781
898
  const bd = this[_bundleUnpacked].has(node)
782
899
  const sw = this[_shrinkwrapInflated].has(node)
900
+ const bundleMissing = this[_bundleMissing].has(node)
783
901
 
784
902
  // check whether we still need to unpack this one.
785
903
  // test the inDepBundle last, since that's potentially a tree walk.
@@ -787,7 +905,7 @@ module.exports = cls => class Reifier extends cls {
787
905
  !node.isRoot && // root node already exists
788
906
  !bd && // already unpacked to read bundle
789
907
  !sw && // already unpacked to read sw
790
- !node.inDepBundle // already unpacked by another dep's bundle
908
+ (bundleMissing || !node.inDepBundle) // already unpacked by another dep's bundle
791
909
 
792
910
  if (doUnpack)
793
911
  unpacks.push(this[_reifyNode](node))
@@ -814,8 +932,26 @@ module.exports = cls => class Reifier extends cls {
814
932
  const moves = this[_retiredPaths]
815
933
  this[_retiredUnchanged] = {}
816
934
  return promiseAllRejectLate(this.diff.children.map(diff => {
817
- const realFolder = (diff.actual || diff.ideal).path
935
+ // skip if nothing was retired
936
+ if (diff.action !== 'CHANGE' && diff.action !== 'REMOVE')
937
+ return
938
+
939
+ const { path: realFolder } = diff.actual
818
940
  const retireFolder = moves[realFolder]
941
+ /* istanbul ignore next - should be impossible */
942
+ debug(() => {
943
+ if (!retireFolder) {
944
+ const er = new Error('trying to un-retire but not retired')
945
+ throw Object.assign(er, {
946
+ realFolder,
947
+ retireFolder,
948
+ actual: diff.actual,
949
+ ideal: diff.ideal,
950
+ action: diff.action,
951
+ })
952
+ }
953
+ })
954
+
819
955
  this[_retiredUnchanged][retireFolder] = []
820
956
  return promiseAllRejectLate(diff.unchanged.map(node => {
821
957
  // no need to roll back links, since we'll just delete them anyway
@@ -823,7 +959,7 @@ module.exports = cls => class Reifier extends cls {
823
959
  return mkdirp(dirname(node.path)).then(() => this[_reifyNode](node))
824
960
 
825
961
  // will have been moved/unpacked along with bundler
826
- if (node.inDepBundle)
962
+ if (node.inDepBundle && !this[_bundleMissing].has(node))
827
963
  return
828
964
 
829
965
  this[_retiredUnchanged][retireFolder].push(node)