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