• 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// 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