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