1'use strict'
2
3const { createWriteStream, promises: fs } = require('graceful-fs')
4const os = require('os')
5const { backOff } = require('exponential-backoff')
6const tar = require('tar')
7const path = require('path')
8const { Transform, promises: { pipeline } } = require('stream')
9const crypto = require('crypto')
10const log = require('./log')
11const semver = require('semver')
12const { download } = require('./download')
13const processRelease = require('./process-release')
14
15const win = process.platform === 'win32'
16
17async function install (gyp, argv) {
18  log.stdout()
19  const release = processRelease(argv, gyp, process.version, process.release)
20  // Detecting target_arch based on logic from create-cnfig-gyp.js. Used on Windows only.
21  const arch = win ? (gyp.opts.target_arch || gyp.opts.arch || process.arch || 'ia32') : ''
22  // Used to prevent downloading tarball if only new node.lib is required on Windows.
23  let shouldDownloadTarball = true
24
25  // Determine which node dev files version we are installing
26  log.verbose('install', 'input version string %j', release.version)
27
28  if (!release.semver) {
29    // could not parse the version string with semver
30    throw new Error('Invalid version number: ' + release.version)
31  }
32
33  if (semver.lt(release.version, '0.8.0')) {
34    throw new Error('Minimum target version is `0.8.0` or greater. Got: ' + release.version)
35  }
36
37  // 0.x.y-pre versions are not published yet and cannot be installed. Bail.
38  if (release.semver.prerelease[0] === 'pre') {
39    log.verbose('detected "pre" node version', release.version)
40    if (!gyp.opts.nodedir) {
41      throw new Error('"pre" versions of node cannot be installed, use the --nodedir flag instead')
42    }
43    log.verbose('--nodedir flag was passed; skipping install', gyp.opts.nodedir)
44    return
45  }
46
47  // flatten version into String
48  log.verbose('install', 'installing version: %s', release.versionDir)
49
50  // the directory where the dev files will be installed
51  const devDir = path.resolve(gyp.devDir, release.versionDir)
52
53  // If '--ensure' was passed, then don't *always* install the version;
54  // check if it is already installed, and only install when needed
55  if (gyp.opts.ensure) {
56    log.verbose('install', '--ensure was passed, so won\'t reinstall if already installed')
57    try {
58      await fs.stat(devDir)
59    } catch (err) {
60      if (err.code === 'ENOENT') {
61        log.verbose('install', 'version not already installed, continuing with install', release.version)
62        try {
63          return await go()
64        } catch (err) {
65          return rollback(err)
66        }
67      } else if (err.code === 'EACCES') {
68        return eaccesFallback(err)
69      }
70      throw err
71    }
72    log.verbose('install', 'version is already installed, need to check "installVersion"')
73    const installVersionFile = path.resolve(devDir, 'installVersion')
74    let installVersion = 0
75    try {
76      const ver = await fs.readFile(installVersionFile, 'ascii')
77      installVersion = parseInt(ver, 10) || 0
78    } catch (err) {
79      if (err.code !== 'ENOENT') {
80        throw err
81      }
82    }
83    log.verbose('got "installVersion"', installVersion)
84    log.verbose('needs "installVersion"', gyp.package.installVersion)
85    if (installVersion < gyp.package.installVersion) {
86      log.verbose('install', 'version is no good; reinstalling')
87      try {
88        return await go()
89      } catch (err) {
90        return rollback(err)
91      }
92    }
93    log.verbose('install', 'version is good')
94    if (win) {
95      log.verbose('on Windows; need to check node.lib')
96      const nodeLibPath = path.resolve(devDir, arch, 'node.lib')
97      try {
98        await fs.stat(nodeLibPath)
99      } catch (err) {
100        if (err.code === 'ENOENT') {
101          log.verbose('install', `version not already installed for ${arch}, continuing with install`, release.version)
102          try {
103            shouldDownloadTarball = false
104            return await go()
105          } catch (err) {
106            return rollback(err)
107          }
108        } else if (err.code === 'EACCES') {
109          return eaccesFallback(err)
110        }
111        throw err
112      }
113    }
114  } else {
115    try {
116      return await go()
117    } catch (err) {
118      return rollback(err)
119    }
120  }
121
122  async function copyDirectory (src, dest) {
123    try {
124      await fs.stat(src)
125    } catch {
126      throw new Error(`Missing source directory for copy: ${src}`)
127    }
128    await fs.mkdir(dest, { recursive: true })
129    const entries = await fs.readdir(src, { withFileTypes: true })
130    for (const entry of entries) {
131      if (entry.isDirectory()) {
132        await copyDirectory(path.join(src, entry.name), path.join(dest, entry.name))
133      } else if (entry.isFile()) {
134        // with parallel installs, copying files may cause file errors on
135        // Windows so use an exponential backoff to resolve collisions
136        await backOff(async () => {
137          try {
138            await fs.copyFile(path.join(src, entry.name), path.join(dest, entry.name))
139          } catch (err) {
140            // if ensure, check if file already exists and that's good enough
141            if (gyp.opts.ensure && err.code === 'EBUSY') {
142              try {
143                await fs.stat(path.join(dest, entry.name))
144                return
145              } catch {}
146            }
147            throw err
148          }
149        })
150      } else {
151        throw new Error('Unexpected file directory entry type')
152      }
153    }
154  }
155
156  async function go () {
157    log.verbose('ensuring devDir is created', devDir)
158
159    // first create the dir for the node dev files
160    try {
161      const created = await fs.mkdir(devDir, { recursive: true })
162
163      if (created) {
164        log.verbose('created devDir', created)
165      }
166    } catch (err) {
167      if (err.code === 'EACCES') {
168        return eaccesFallback(err)
169      }
170
171      throw err
172    }
173
174    // now download the node tarball
175    const tarPath = gyp.opts.tarball
176    let extractErrors = false
177    let extractCount = 0
178    const contentShasums = {}
179    const expectShasums = {}
180
181    // checks if a file to be extracted from the tarball is valid.
182    // only .h header files and the gyp files get extracted
183    function isValid (path) {
184      const isValid = valid(path)
185      if (isValid) {
186        log.verbose('extracted file from tarball', path)
187        extractCount++
188      } else {
189        // invalid
190        log.silly('ignoring from tarball', path)
191      }
192      return isValid
193    }
194
195    function onwarn (code, message) {
196      extractErrors = true
197      log.error('error while extracting tarball', code, message)
198    }
199
200    // download the tarball and extract!
201    // Ommited on Windows if only new node.lib is required
202
203    // on Windows there can be file errors from tar if parallel installs
204    // are happening (not uncommon with multiple native modules) so
205    // extract the tarball to a temp directory first and then copy over
206    const tarExtractDir = win ? await fs.mkdtemp(path.join(os.tmpdir(), 'node-gyp-tmp-')) : devDir
207
208    try {
209      if (shouldDownloadTarball) {
210        if (tarPath) {
211          await tar.extract({
212            file: tarPath,
213            strip: 1,
214            filter: isValid,
215            onwarn,
216            cwd: tarExtractDir
217          })
218        } else {
219          try {
220            const res = await download(gyp, release.tarballUrl)
221
222            if (res.status !== 200) {
223              throw new Error(`${res.status} response downloading ${release.tarballUrl}`)
224            }
225
226            await pipeline(
227              res.body,
228              // content checksum
229              new ShaSum((_, checksum) => {
230                const filename = path.basename(release.tarballUrl).trim()
231                contentShasums[filename] = checksum
232                log.verbose('content checksum', filename, checksum)
233              }),
234              tar.extract({
235                strip: 1,
236                cwd: tarExtractDir,
237                filter: isValid,
238                onwarn
239              })
240            )
241          } catch (err) {
242          // something went wrong downloading the tarball?
243            if (err.code === 'ENOTFOUND') {
244              throw new Error('This is most likely not a problem with node-gyp or the package itself and\n' +
245              'is related to network connectivity. In most cases you are behind a proxy or have bad \n' +
246              'network settings.')
247            }
248            throw err
249          }
250        }
251
252        // invoked after the tarball has finished being extracted
253        if (extractErrors || extractCount === 0) {
254          throw new Error('There was a fatal problem while downloading/extracting the tarball')
255        }
256
257        log.verbose('tarball', 'done parsing tarball')
258      }
259
260      const installVersionPath = path.resolve(tarExtractDir, 'installVersion')
261      await Promise.all([
262      // need to download node.lib
263        ...(win ? [downloadNodeLib()] : []),
264        // write the "installVersion" file
265        fs.writeFile(installVersionPath, gyp.package.installVersion + '\n'),
266        // Only download SHASUMS.txt if we downloaded something in need of SHA verification
267        ...(!tarPath || win ? [downloadShasums()] : [])
268      ])
269
270      log.verbose('download contents checksum', JSON.stringify(contentShasums))
271      // check content shasums
272      for (const k in contentShasums) {
273        log.verbose('validating download checksum for ' + k, '(%s == %s)', contentShasums[k], expectShasums[k])
274        if (contentShasums[k] !== expectShasums[k]) {
275          throw new Error(k + ' local checksum ' + contentShasums[k] + ' not match remote ' + expectShasums[k])
276        }
277      }
278
279      // copy over the files from the temp tarball extract directory to devDir
280      if (tarExtractDir !== devDir) {
281        await copyDirectory(tarExtractDir, devDir)
282      }
283    } finally {
284      if (tarExtractDir !== devDir) {
285        try {
286          // try to cleanup temp dir
287          await fs.rm(tarExtractDir, { recursive: true })
288        } catch {
289          log.warn('failed to clean up temp tarball extract directory')
290        }
291      }
292    }
293
294    async function downloadShasums () {
295      log.verbose('check download content checksum, need to download `SHASUMS256.txt`...')
296      log.verbose('checksum url', release.shasumsUrl)
297
298      const res = await download(gyp, release.shasumsUrl)
299
300      if (res.status !== 200) {
301        throw new Error(`${res.status}  status code downloading checksum`)
302      }
303
304      for (const line of (await res.text()).trim().split('\n')) {
305        const items = line.trim().split(/\s+/)
306        if (items.length !== 2) {
307          return
308        }
309
310        // 0035d18e2dcf9aad669b1c7c07319e17abfe3762  ./node-v0.11.4.tar.gz
311        const name = items[1].replace(/^\.\//, '')
312        expectShasums[name] = items[0]
313      }
314
315      log.verbose('checksum data', JSON.stringify(expectShasums))
316    }
317
318    async function downloadNodeLib () {
319      log.verbose('on Windows; need to download `' + release.name + '.lib`...')
320      const dir = path.resolve(tarExtractDir, arch)
321      const targetLibPath = path.resolve(dir, release.name + '.lib')
322      const { libUrl, libPath } = release[arch]
323      const name = `${arch} ${release.name}.lib`
324      log.verbose(name, 'dir', dir)
325      log.verbose(name, 'url', libUrl)
326
327      await fs.mkdir(dir, { recursive: true })
328      log.verbose('streaming', name, 'to:', targetLibPath)
329
330      const res = await download(gyp, libUrl)
331
332      // Since only required node.lib is downloaded throw error if it is not fetched
333      if (res.status !== 200) {
334        throw new Error(`${res.status} status code downloading ${name}`)
335      }
336
337      return pipeline(
338        res.body,
339        new ShaSum((_, checksum) => {
340          contentShasums[libPath] = checksum
341          log.verbose('content checksum', libPath, checksum)
342        }),
343        createWriteStream(targetLibPath)
344      )
345    } // downloadNodeLib()
346  } // go()
347
348  /**
349   * Checks if a given filename is "valid" for this installation.
350   */
351
352  function valid (file) {
353    // header files
354    const extname = path.extname(file)
355    return extname === '.h' || extname === '.gypi'
356  }
357
358  async function rollback (err) {
359    log.warn('install', 'got an error, rolling back install')
360    // roll-back the install if anything went wrong
361    await gyp.commands.remove([release.versionDir])
362    throw err
363  }
364
365  /**
366   * The EACCES fallback is a workaround for npm's `sudo` behavior, where
367   * it drops the permissions before invoking any child processes (like
368   * node-gyp). So what happens is the "nobody" user doesn't have
369   * permission to create the dev dir. As a fallback, make the tmpdir() be
370   * the dev dir for this installation. This is not ideal, but at least
371   * the compilation will succeed...
372   */
373
374  async function eaccesFallback (err) {
375    const noretry = '--node_gyp_internal_noretry'
376    if (argv.indexOf(noretry) !== -1) {
377      throw err
378    }
379    const tmpdir = os.tmpdir()
380    gyp.devDir = path.resolve(tmpdir, '.node-gyp')
381    let userString = ''
382    try {
383      // os.userInfo can fail on some systems, it's not critical here
384      userString = ` ("${os.userInfo().username}")`
385    } catch (e) {}
386    log.warn('EACCES', 'current user%s does not have permission to access the dev dir "%s"', userString, devDir)
387    log.warn('EACCES', 'attempting to reinstall using temporary dev dir "%s"', gyp.devDir)
388    if (process.cwd() === tmpdir) {
389      log.verbose('tmpdir == cwd', 'automatically will remove dev files after to save disk space')
390      gyp.todo.push({ name: 'remove', args: argv })
391    }
392    return gyp.commands.install([noretry].concat(argv))
393  }
394}
395
396class ShaSum extends Transform {
397  constructor (callback) {
398    super()
399    this._callback = callback
400    this._digester = crypto.createHash('sha256')
401  }
402
403  _transform (chunk, _, callback) {
404    this._digester.update(chunk)
405    callback(null, chunk)
406  }
407
408  _flush (callback) {
409    this._callback(null, this._digester.digest('hex'))
410    callback()
411  }
412}
413
414module.exports = install
415module.exports.usage = 'Install node development files for the specified node version.'
416