1const { createHook, executionAsyncId } = require('async_hooks') 2const { EventEmitter } = require('events') 3const { homedir, tmpdir } = require('os') 4const { dirname, join } = require('path') 5const { mkdir, rm } = require('fs/promises') 6const mockLogs = require('./mock-logs') 7const pkg = require('../../package.json') 8 9const chain = new Map() 10const sandboxes = new Map() 11 12// keep a reference to the real process 13const _process = process 14 15createHook({ 16 init: (asyncId, type, triggerAsyncId, resource) => { 17 // track parentage of asyncIds 18 chain.set(asyncId, triggerAsyncId) 19 }, 20 before: (asyncId) => { 21 // find the nearest parent id that has a sandbox 22 let parent = asyncId 23 while (chain.has(parent) && !sandboxes.has(parent)) { 24 parent = chain.get(parent) 25 } 26 27 process = sandboxes.has(parent) 28 ? sandboxes.get(parent) 29 : _process 30 }, 31}).enable() 32 33const _data = Symbol('sandbox.data') 34const _dirs = Symbol('sandbox.dirs') 35const _test = Symbol('sandbox.test') 36const _mocks = Symbol('sandbox.mocks') 37const _npm = Symbol('sandbox.npm') 38const _parent = Symbol('sandbox.parent') 39const _output = Symbol('sandbox.output') 40const _proxy = Symbol('sandbox.proxy') 41const _get = Symbol('sandbox.proxy.get') 42const _set = Symbol('sandbox.proxy.set') 43const _logs = Symbol('sandbox.logs') 44 45// these config keys can be redacted widely 46const redactedDefaults = [ 47 'tmp', 48] 49 50// we can't just replace these values everywhere because they're known to be 51// very short strings that could be present all over the place, so we only 52// replace them if they're located within quotes for now 53const vagueRedactedDefaults = [ 54 'editor', 55 'shell', 56] 57 58const normalize = (str) => str 59 .replace(/\r\n/g, '\n') // normalize line endings (for ini) 60 .replace(/[A-z]:\\/g, '\\') // turn windows roots to posix ones 61 .replace(/\\+/g, '/') // replace \ with / 62 63class Sandbox extends EventEmitter { 64 constructor (test, options = {}) { 65 super() 66 67 this[_test] = test 68 this[_mocks] = options.mocks || {} 69 this[_data] = new Map() 70 this[_output] = [] 71 const tempDir = `${test.testdirName}-sandbox` 72 this[_dirs] = { 73 temp: tempDir, 74 global: options.global || join(tempDir, 'global'), 75 home: options.home || join(tempDir, 'home'), 76 project: options.project || join(tempDir, 'project'), 77 cache: options.cache || join(tempDir, 'cache'), 78 } 79 80 this[_proxy] = new Proxy(_process, { 81 get: this[_get].bind(this), 82 set: this[_set].bind(this), 83 }) 84 this[_proxy].env = { ...options.env } 85 this[_proxy].argv = [] 86 87 test.cleanSnapshot = this.cleanSnapshot.bind(this) 88 test.afterEach(() => this.reset()) 89 test.teardown(() => this.teardown()) 90 } 91 92 get config () { 93 return this[_npm] && this[_npm].config 94 } 95 96 get logs () { 97 return this[_logs] 98 } 99 100 get global () { 101 return this[_dirs].global 102 } 103 104 get home () { 105 return this[_dirs].home 106 } 107 108 get project () { 109 return this[_dirs].project 110 } 111 112 get cache () { 113 return this[_dirs].cache 114 } 115 116 get process () { 117 return this[_proxy] 118 } 119 120 get output () { 121 return this[_output].map((line) => line.join(' ')).join('\n') 122 } 123 124 cleanSnapshot (snapshot) { 125 let clean = normalize(snapshot) 126 127 const viewer = _process.platform === 'win32' 128 ? /"browser"([^:]+|$)/g 129 : /"man"([^:]+|$)/g 130 131 // the global prefix is platform dependent 132 const realGlobalPrefix = _process.platform === 'win32' 133 ? dirname(_process.execPath) 134 : dirname(dirname(_process.execPath)) 135 136 const cache = _process.platform === 'win32' 137 ? /\{HOME\}\/npm-cache(\r?\n|"|\/|$)/g 138 : /\{HOME\}\/\.npm(\n|"|\/|$)/g 139 140 // and finally replace some paths we know could be present 141 clean = clean 142 .replace(viewer, '"{VIEWER}"$1') 143 .split(normalize(this[_proxy].execPath)).join('{EXECPATH}') 144 .split(normalize(_process.execPath)).join('{REALEXECPATH}') 145 .split(normalize(this.global)).join('{GLOBALPREFIX}') 146 .split(normalize(realGlobalPrefix)).join('{REALGLOBALREFIX}') 147 .split(normalize(this.project)).join('{LOCALPREFIX}') 148 .split(normalize(this.home)).join('{HOME}') 149 .replace(cache, '{CACHE}$1') 150 .split(normalize(dirname(dirname(__dirname)))).join('{NPMDIR}') 151 .split(normalize(tmpdir())).join('{TMP}') 152 .split(normalize(homedir())).join('{REALHOME}') 153 .split(this[_proxy].platform).join('{PLATFORM}') 154 .split(this[_proxy].arch).join('{ARCH}') 155 .replace(new RegExp(process.version, 'g'), '{NODE-VERSION}') 156 .replace(new RegExp(pkg.version, 'g'), '{NPM-VERSION}') 157 158 // We do the defaults after everything else so that they don't cause the 159 // other cleaners to miss values we would have clobbered here. For 160 // instance if execPath is /home/user/.nvm/versions/node/1.0.0/bin/node, 161 // and we replaced the node version first, the real execPath we're trying 162 // to replace would no longer be represented, and be missed. 163 if (this[_npm]) { 164 // replace default config values with placeholders 165 for (const name of redactedDefaults) { 166 const value = this[_npm].config.defaults[name] 167 clean = clean.split(normalize(value)).join(`{${name.toUpperCase()}}`) 168 } 169 170 // replace vague default config values that are present within quotes 171 // with placeholders 172 for (const name of vagueRedactedDefaults) { 173 const value = this[_npm].config.defaults[name] 174 clean = clean.split(`"${normalize(value)}"`).join(`"{${name.toUpperCase()}}"`) 175 } 176 } 177 178 return clean 179 } 180 181 // test.afterEach hook 182 reset () { 183 this.removeAllListeners() 184 this[_parent] = undefined 185 this[_output] = [] 186 this[_data].clear() 187 this[_proxy].env = {} 188 this[_proxy].argv = [] 189 this[_npm] = undefined 190 } 191 192 // test.teardown hook 193 teardown () { 194 if (this[_parent]) { 195 const sandboxProcess = sandboxes.get(this[_parent]) 196 sandboxProcess.removeAllListeners('log') 197 sandboxes.delete(this[_parent]) 198 } 199 if (this[_npm]) { 200 this[_npm].unload() 201 } 202 return rm(this[_dirs].temp, { recursive: true, force: true }).catch(() => null) 203 } 204 205 // proxy get handler 206 [_get] (target, prop, receiver) { 207 if (this[_data].has(prop)) { 208 return this[_data].get(prop) 209 } 210 211 if (this[prop] !== undefined) { 212 return Reflect.get(this, prop, this) 213 } 214 215 return Reflect.get(target, prop, receiver) 216 } 217 218 // proxy set handler 219 [_set] (target, prop, value) { 220 if (prop === 'env') { 221 value = { 222 ...value, 223 HOME: this.home, 224 } 225 } 226 227 if (prop === 'argv') { 228 value = [ 229 process.execPath, 230 join(dirname(process.execPath), 'npm'), 231 ...value, 232 ] 233 } 234 235 return this[_data].set(prop, value) 236 } 237 238 async run (command, argv = []) { 239 await Promise.all([ 240 mkdir(this.project, { recursive: true }), 241 mkdir(this.home, { recursive: true }), 242 mkdir(this.global, { recursive: true }), 243 ]) 244 245 // attach the sandbox process now, doing it after the promise above is 246 // necessary to make sure that only async calls spawned as part of this 247 // call to run will receive the sandbox. if we attach it too early, we 248 // end up interfering with tap 249 this[_parent] = executionAsyncId() 250 this[_data].set('_asyncId', this[_parent]) 251 sandboxes.set(this[_parent], this[_proxy]) 252 process = this[_proxy] 253 254 this[_proxy].argv = [ 255 '--prefix', this.project, 256 '--userconfig', join(this.home, '.npmrc'), 257 '--globalconfig', join(this.global, 'npmrc'), 258 '--cache', this.cache, 259 command, 260 ...argv, 261 ] 262 263 const mockedLogs = mockLogs(this[_mocks]) 264 this[_logs] = mockedLogs.logs 265 const definitions = this[_test].mock('@npmcli/config/lib/definitions') 266 const Npm = this[_test].mock('../../lib/npm.js', { 267 '@npmcli/config/lib/definitions': definitions, 268 '../../lib/utils/update-notifier.js': async () => {}, 269 ...this[_mocks], 270 ...mockedLogs.logMocks, 271 }) 272 this.process.on('log', (l, ...args) => { 273 if (l !== 'pause' && l !== 'resume') { 274 this[_logs].push([l, ...args]) 275 } 276 }) 277 278 this[_npm] = new Npm() 279 this[_npm].output = (...args) => this[_output].push(args) 280 await this[_npm].load() 281 282 const cmd = this[_npm].argv.shift() 283 return this[_npm].exec(cmd, this[_npm].argv) 284 } 285 286 async complete (command, argv, partial) { 287 if (!Array.isArray(argv)) { 288 partial = argv 289 argv = [] 290 } 291 292 await Promise.all([ 293 mkdir(this.project, { recursive: true }), 294 mkdir(this.home, { recursive: true }), 295 mkdir(this.global, { recursive: true }), 296 ]) 297 298 // attach the sandbox process now, doing it after the promise above is 299 // necessary to make sure that only async calls spawned as part of this 300 // call to run will receive the sandbox. if we attach it too early, we 301 // end up interfering with tap 302 this[_parent] = executionAsyncId() 303 this[_data].set('_asyncId', this[_parent]) 304 sandboxes.set(this[_parent], this[_proxy]) 305 process = this[_proxy] 306 307 this[_proxy].argv = [ 308 '--prefix', this.project, 309 '--userconfig', join(this.home, '.npmrc'), 310 '--globalconfig', join(this.global, 'npmrc'), 311 '--cache', this.cache, 312 command, 313 ...argv, 314 ] 315 316 const mockedLogs = mockLogs(this[_mocks]) 317 this[_logs] = mockedLogs.logs 318 const definitions = this[_test].mock('@npmcli/config/lib/definitions') 319 const Npm = this[_test].mock('../../lib/npm.js', { 320 '@npmcli/config/lib/definitions': definitions, 321 '../../lib/utils/update-notifier.js': async () => {}, 322 ...this[_mocks], 323 ...mockedLogs.logMocks, 324 }) 325 this.process.on('log', (l, ...args) => { 326 if (l !== 'pause' && l !== 'resume') { 327 this[_logs].push([l, ...args]) 328 } 329 }) 330 331 this[_npm] = new Npm() 332 this[_npm].output = (...args) => this[_output].push(args) 333 await this[_npm].load() 334 335 const Cmd = Npm.cmd(command) 336 return Cmd.completion({ 337 partialWord: partial, 338 conf: { 339 argv: { 340 remain: ['npm', command, ...argv], 341 }, 342 }, 343 }) 344 } 345} 346 347module.exports = Sandbox 348