• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const _makeIdealGraph = Symbol('makeIdealGraph')
2const _createIsolatedTree = Symbol.for('createIsolatedTree')
3const _createBundledTree = Symbol('createBundledTree')
4const fs = require('fs')
5const pacote = require('pacote')
6const { join } = require('path')
7const { depth } = require('treeverse')
8const crypto = require('crypto')
9
10// cache complicated function results
11const memoize = (fn) => {
12  const memo = new Map()
13  return async function (arg) {
14    const key = arg
15    if (memo.has(key)) {
16      return memo.get(key)
17    }
18    const result = {}
19    memo.set(key, result)
20    await fn(result, arg)
21    return result
22  }
23}
24
25module.exports = cls => class IsolatedReifier extends cls {
26  /**
27   * Create an ideal graph.
28   *
29   * An implementation of npm RFC-0042
30   * https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
31   *
32   * This entire file should be considered technical debt that will be resolved
33   * with an Arborist refactor or rewrite. Embedded logic in Nodes and Links,
34   * and the incremental state of building trees and reifying contains too many
35   * assumptions to do a linked mode properly.
36   *
37   * Instead, this approach takes a tree built from build-ideal-tree, and
38   * returns a new tree-like structure without the embedded logic of Node and
39   * Link classes.
40   *
41   * Since the RFC requires leaving the package-lock in place, this approach
42   * temporarily replaces the tree state for a couple of steps of reifying.
43   *
44   **/
45  async [_makeIdealGraph] (options) {
46    /* Make sure that the ideal tree is build as the rest of
47     * the algorithm depends on it.
48     */
49    const bitOpt = {
50      ...options,
51      complete: false,
52    }
53    await this.buildIdealTree(bitOpt)
54    const idealTree = this.idealTree
55
56    this.rootNode = {}
57    const root = this.rootNode
58    this.counter = 0
59
60    // memoize to cache generating proxy Nodes
61    this.externalProxyMemo = memoize(this.externalProxy.bind(this))
62    this.workspaceProxyMemo = memoize(this.workspaceProxy.bind(this))
63
64    root.external = []
65    root.isProjectRoot = true
66    root.localLocation = idealTree.location
67    root.localPath = idealTree.path
68    root.workspaces = await Promise.all(
69      Array.from(idealTree.fsChildren.values(), this.workspaceProxyMemo))
70    const processed = new Set()
71    const queue = [idealTree, ...idealTree.fsChildren]
72    while (queue.length !== 0) {
73      const next = queue.pop()
74      if (processed.has(next.location)) {
75        continue
76      }
77      processed.add(next.location)
78      next.edgesOut.forEach(e => {
79        if (!e.to || (next.package.bundleDependencies || next.package.bundledDependencies || []).includes(e.to.name)) {
80          return
81        }
82        queue.push(e.to)
83      })
84      if (!next.isProjectRoot && !next.isWorkspace) {
85        root.external.push(await this.externalProxyMemo(next))
86      }
87    }
88
89    await this.assignCommonProperties(idealTree, root)
90
91    this.idealGraph = root
92  }
93
94  async workspaceProxy (result, node) {
95    result.localLocation = node.location
96    result.localPath = node.path
97    result.isWorkspace = true
98    result.resolved = node.resolved
99    await this.assignCommonProperties(node, result)
100  }
101
102  async externalProxy (result, node) {
103    await this.assignCommonProperties(node, result)
104    if (node.hasShrinkwrap) {
105      const dir = join(
106        node.root.path,
107        'node_modules',
108        '.store',
109        `${node.name}@${node.version}`
110      )
111      fs.mkdirSync(dir, { recursive: true })
112      // TODO this approach feels wrong
113      // and shouldn't be necessary for shrinkwraps
114      await pacote.extract(node.resolved, dir, {
115        ...this.options,
116        resolved: node.resolved,
117        integrity: node.integrity,
118      })
119      const Arborist = this.constructor
120      const arb = new Arborist({ ...this.options, path: dir })
121      await arb[_makeIdealGraph]({ dev: false })
122      this.rootNode.external.push(...arb.idealGraph.external)
123      arb.idealGraph.external.forEach(e => {
124        e.root = this.rootNode
125        e.id = `${node.id}=>${e.id}`
126      })
127      result.localDependencies = []
128      result.externalDependencies = arb.idealGraph.externalDependencies
129      result.externalOptionalDependencies = arb.idealGraph.externalOptionalDependencies
130      result.dependencies = [
131        ...result.externalDependencies,
132        ...result.localDependencies,
133        ...result.externalOptionalDependencies,
134      ]
135    }
136    result.optional = node.optional
137    result.resolved = node.resolved
138    result.version = node.version
139  }
140
141  async assignCommonProperties (node, result) {
142    function validEdgesOut (node) {
143      return [...node.edgesOut.values()].filter(e => e.to && e.to.target && !(node.package.bundledDepenedencies || node.package.bundleDependencies || []).includes(e.to.name))
144    }
145    const edges = validEdgesOut(node)
146    const optionalDeps = edges.filter(e => e.optional).map(e => e.to.target)
147    const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
148
149    result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(this.workspaceProxyMemo))
150    result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace).map(this.externalProxyMemo))
151    result.externalOptionalDependencies = await Promise.all(optionalDeps.map(this.externalProxyMemo))
152    result.dependencies = [
153      ...result.externalDependencies,
154      ...result.localDependencies,
155      ...result.externalOptionalDependencies,
156    ]
157    result.root = this.rootNode
158    result.id = this.counter++
159    result.name = node.name
160    result.package = { ...node.package }
161    result.package.bundleDependencies = undefined
162    result.hasInstallScript = node.hasInstallScript
163  }
164
165  async [_createBundledTree] () {
166    // TODO: make sure that idealTree object exists
167    const idealTree = this.idealTree
168    // TODO: test workspaces having bundled deps
169    const queue = []
170
171    for (const [, edge] of idealTree.edgesOut) {
172      if (edge.to && (idealTree.package.bundleDependencies || idealTree.package.bundledDependencies || []).includes(edge.to.name)) {
173        queue.push({ from: idealTree, to: edge.to })
174      }
175    }
176    for (const child of idealTree.fsChildren) {
177      for (const [, edge] of child.edgesOut) {
178        if (edge.to && (child.package.bundleDependencies || child.package.bundledDependencies || []).includes(edge.to.name)) {
179          queue.push({ from: child, to: edge.to })
180        }
181      }
182    }
183
184    const processed = new Set()
185    const nodes = new Map()
186    const edges = []
187    while (queue.length !== 0) {
188      const nextEdge = queue.pop()
189      const key = `${nextEdge.from.location}=>${nextEdge.to.location}`
190      // should be impossible, unless bundled is duped
191      /* istanbul ignore next */
192      if (processed.has(key)) {
193        continue
194      }
195      processed.add(key)
196      const from = nextEdge.from
197      if (!from.isRoot && !from.isWorkspace) {
198        nodes.set(from.location, { location: from.location, resolved: from.resolved, name: from.name, optional: from.optional, pkg: { ...from.package, bundleDependencies: undefined } })
199      }
200      const to = nextEdge.to
201      nodes.set(to.location, { location: to.location, resolved: to.resolved, name: to.name, optional: to.optional, pkg: { ...to.package, bundleDependencies: undefined } })
202      edges.push({ from: from.isRoot ? 'root' : from.location, to: to.location })
203
204      to.edgesOut.forEach(e => {
205        // an edge out should always have a to
206        /* istanbul ignore else */
207        if (e.to) {
208          queue.push({ from: e.from, to: e.to })
209        }
210      })
211    }
212    return { edges, nodes }
213  }
214
215  async [_createIsolatedTree] (idealTree) {
216    await this[_makeIdealGraph](this.options)
217
218    const proxiedIdealTree = this.idealGraph
219
220    const bundledTree = await this[_createBundledTree]()
221
222    const treeHash = (startNode) => {
223      // generate short hash based on the dependency tree
224      // starting at this node
225      const deps = []
226      const branch = []
227      depth({
228        tree: startNode,
229        getChildren: node => node.dependencies,
230        filter: node => node,
231        visit: node => {
232          branch.push(`${node.name}@${node.version}`)
233          deps.push(`${branch.join('->')}::${node.resolved}`)
234        },
235        leave: () => {
236          branch.pop()
237        },
238      })
239      deps.sort()
240      return crypto.createHash('shake256', { outputLength: 16 })
241        .update(deps.join(','))
242        .digest('base64')
243        // Node v14 doesn't support base64url
244        .replace(/\+/g, '-')
245        .replace(/\//g, '_')
246        .replace(/=+$/m, '')
247    }
248
249    const getKey = (idealTreeNode) => {
250      return `${idealTreeNode.name}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
251    }
252
253    const root = {
254      fsChildren: [],
255      integrity: null,
256      inventory: new Map(),
257      isLink: false,
258      isRoot: true,
259      binPaths: [],
260      edgesIn: new Set(),
261      edgesOut: new Map(),
262      hasShrinkwrap: false,
263      parent: null,
264      // TODO: we should probably not reference this.idealTree
265      resolved: this.idealTree.resolved,
266      isTop: true,
267      path: proxiedIdealTree.root.localPath,
268      realpath: proxiedIdealTree.root.localPath,
269      package: proxiedIdealTree.root.package,
270      meta: { loadedFromDisk: false },
271      global: false,
272      isProjectRoot: true,
273      children: [],
274    }
275    // root.inventory.set('', t)
276    // root.meta = this.idealTree.meta
277    // TODO We should mock better the inventory object because it is used by audit-report.js ... maybe
278    root.inventory.query = () => {
279      return []
280    }
281    const processed = new Set()
282    proxiedIdealTree.workspaces.forEach(c => {
283      const workspace = {
284        edgesIn: new Set(),
285        edgesOut: new Map(),
286        children: [],
287        hasInstallScript: c.hasInstallScript,
288        binPaths: [],
289        package: c.package,
290        location: c.localLocation,
291        path: c.localPath,
292        realpath: c.localPath,
293        resolved: c.resolved,
294      }
295      root.fsChildren.push(workspace)
296      root.inventory.set(workspace.location, workspace)
297    })
298    const generateChild = (node, location, pkg, inStore) => {
299      const newChild = {
300        global: false,
301        globalTop: false,
302        isProjectRoot: false,
303        isTop: false,
304        location,
305        name: node.name,
306        optional: node.optional,
307        top: { path: proxiedIdealTree.root.localPath },
308        children: [],
309        edgesIn: new Set(),
310        edgesOut: new Map(),
311        binPaths: [],
312        fsChildren: [],
313        /* istanbul ignore next -- emulate Node */
314        getBundler () {
315          return null
316        },
317        hasShrinkwrap: false,
318        inDepBundle: false,
319        integrity: null,
320        isLink: false,
321        isRoot: false,
322        isInStore: inStore,
323        path: join(proxiedIdealTree.root.localPath, location),
324        realpath: join(proxiedIdealTree.root.localPath, location),
325        resolved: node.resolved,
326        version: pkg.version,
327        package: pkg,
328      }
329      newChild.target = newChild
330      root.children.push(newChild)
331      root.inventory.set(newChild.location, newChild)
332    }
333    proxiedIdealTree.external.forEach(c => {
334      const key = getKey(c)
335      if (processed.has(key)) {
336        return
337      }
338      processed.add(key)
339      const location = join('node_modules', '.store', key, 'node_modules', c.name)
340      generateChild(c, location, c.package, true)
341    })
342    bundledTree.nodes.forEach(node => {
343      generateChild(node, node.location, node.pkg, false)
344    })
345    bundledTree.edges.forEach(e => {
346      const from = e.from === 'root' ? root : root.inventory.get(e.from)
347      const to = root.inventory.get(e.to)
348      // Maybe optional should be propagated from the original edge
349      const edge = { optional: false, from, to }
350      from.edgesOut.set(to.name, edge)
351      to.edgesIn.add(edge)
352    })
353    const memo = new Set()
354
355    function processEdges (node, externalEdge) {
356      externalEdge = !!externalEdge
357      const key = getKey(node)
358      if (memo.has(key)) {
359        return
360      }
361      memo.add(key)
362
363      let from, nmFolder
364      if (externalEdge) {
365        const fromLocation = join('node_modules', '.store', key, 'node_modules', node.name)
366        from = root.children.find(c => c.location === fromLocation)
367        nmFolder = join('node_modules', '.store', key, 'node_modules')
368      } else {
369        from = node.isProjectRoot ? root : root.fsChildren.find(c => c.location === node.localLocation)
370        nmFolder = join(node.localLocation, 'node_modules')
371      }
372
373      const processDeps = (dep, optional, external) => {
374        optional = !!optional
375        external = !!external
376
377        const location = join(nmFolder, dep.name)
378        const binNames = dep.package.bin && Object.keys(dep.package.bin) || []
379        const toKey = getKey(dep)
380
381        let target
382        if (external) {
383          const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.name)
384          target = root.children.find(c => c.location === toLocation)
385        } else {
386          target = root.fsChildren.find(c => c.location === dep.localLocation)
387        }
388        // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
389
390        binNames.forEach(bn => {
391          target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
392        })
393
394        const link = {
395          global: false,
396          globalTop: false,
397          isProjectRoot: false,
398          edgesIn: new Set(),
399          edgesOut: new Map(),
400          binPaths: [],
401          isTop: false,
402          optional,
403          location: location,
404          path: join(dep.root.localPath, nmFolder, dep.name),
405          realpath: target.path,
406          name: toKey,
407          resolved: dep.resolved,
408          top: { path: dep.root.localPath },
409          children: [],
410          fsChildren: [],
411          isLink: true,
412          isStoreLink: true,
413          isRoot: false,
414          package: { _id: 'abc', bundleDependencies: undefined, deprecated: undefined, bin: target.package.bin, scripts: dep.package.scripts },
415          target,
416        }
417        const newEdge1 = { optional, from, to: link }
418        from.edgesOut.set(dep.name, newEdge1)
419        link.edgesIn.add(newEdge1)
420        const newEdge2 = { optional: false, from: link, to: target }
421        link.edgesOut.set(dep.name, newEdge2)
422        target.edgesIn.add(newEdge2)
423        root.children.push(link)
424      }
425
426      for (const dep of node.localDependencies) {
427        processEdges(dep, false)
428        // nonOptional, local
429        processDeps(dep, false, false)
430      }
431      for (const dep of node.externalDependencies) {
432        processEdges(dep, true)
433        // nonOptional, external
434        processDeps(dep, false, true)
435      }
436      for (const dep of node.externalOptionalDependencies) {
437        processEdges(dep, true)
438        // optional, external
439        processDeps(dep, true, true)
440      }
441    }
442
443    processEdges(proxiedIdealTree, false)
444    for (const node of proxiedIdealTree.workspaces) {
445      processEdges(node, false)
446    }
447    root.children.forEach(c => c.parent = root)
448    root.children.forEach(c => c.root = root)
449    root.root = root
450    root.target = root
451    return root
452  }
453}
454