• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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