• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict'
2module.exports = npa
3module.exports.resolve = resolve
4module.exports.toPurl = toPurl
5module.exports.Result = Result
6
7const url = require('url')
8const HostedGit = require('hosted-git-info')
9const semver = require('semver')
10const path = global.FAKE_WINDOWS ? require('path').win32 : require('path')
11const validatePackageName = require('validate-npm-package-name')
12const { homedir } = require('os')
13const log = require('proc-log')
14
15const isWindows = process.platform === 'win32' || global.FAKE_WINDOWS
16const hasSlashes = isWindows ? /\\|[/]/ : /[/]/
17const isURL = /^(?:git[+])?[a-z]+:/i
18const isGit = /^[^@]+@[^:.]+\.[^:]+:.+$/i
19const isFilename = /[.](?:tgz|tar.gz|tar)$/i
20
21function npa (arg, where) {
22  let name
23  let spec
24  if (typeof arg === 'object') {
25    if (arg instanceof Result && (!where || where === arg.where)) {
26      return arg
27    } else if (arg.name && arg.rawSpec) {
28      return npa.resolve(arg.name, arg.rawSpec, where || arg.where)
29    } else {
30      return npa(arg.raw, where || arg.where)
31    }
32  }
33  const nameEndsAt = arg[0] === '@' ? arg.slice(1).indexOf('@') + 1 : arg.indexOf('@')
34  const namePart = nameEndsAt > 0 ? arg.slice(0, nameEndsAt) : arg
35  if (isURL.test(arg)) {
36    spec = arg
37  } else if (isGit.test(arg)) {
38    spec = `git+ssh://${arg}`
39  } else if (namePart[0] !== '@' && (hasSlashes.test(namePart) || isFilename.test(namePart))) {
40    spec = arg
41  } else if (nameEndsAt > 0) {
42    name = namePart
43    spec = arg.slice(nameEndsAt + 1) || '*'
44  } else {
45    const valid = validatePackageName(arg)
46    if (valid.validForOldPackages) {
47      name = arg
48      spec = '*'
49    } else {
50      spec = arg
51    }
52  }
53  return resolve(name, spec, where, arg)
54}
55
56const isFilespec = isWindows ? /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/ : /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
57
58function resolve (name, spec, where, arg) {
59  const res = new Result({
60    raw: arg,
61    name: name,
62    rawSpec: spec,
63    fromArgument: arg != null,
64  })
65
66  if (name) {
67    res.setName(name)
68  }
69
70  if (spec && (isFilespec.test(spec) || /^file:/i.test(spec))) {
71    return fromFile(res, where)
72  } else if (spec && /^npm:/i.test(spec)) {
73    return fromAlias(res, where)
74  }
75
76  const hosted = HostedGit.fromUrl(spec, {
77    noGitPlus: true,
78    noCommittish: true,
79  })
80  if (hosted) {
81    return fromHostedGit(res, hosted)
82  } else if (spec && isURL.test(spec)) {
83    return fromURL(res)
84  } else if (spec && (hasSlashes.test(spec) || isFilename.test(spec))) {
85    return fromFile(res, where)
86  } else {
87    return fromRegistry(res)
88  }
89}
90
91const defaultRegistry = 'https://registry.npmjs.org'
92
93function toPurl (arg, reg = defaultRegistry) {
94  const res = npa(arg)
95
96  if (res.type !== 'version') {
97    throw invalidPurlType(res.type, res.raw)
98  }
99
100  // URI-encode leading @ of scoped packages
101  let purl = 'pkg:npm/' + res.name.replace(/^@/, '%40') + '@' + res.rawSpec
102  if (reg !== defaultRegistry) {
103    purl += '?repository_url=' + reg
104  }
105
106  return purl
107}
108
109function invalidPackageName (name, valid, raw) {
110  // eslint-disable-next-line max-len
111  const err = new Error(`Invalid package name "${name}" of package "${raw}": ${valid.errors.join('; ')}.`)
112  err.code = 'EINVALIDPACKAGENAME'
113  return err
114}
115
116function invalidTagName (name, raw) {
117  // eslint-disable-next-line max-len
118  const err = new Error(`Invalid tag name "${name}" of package "${raw}": Tags may not have any characters that encodeURIComponent encodes.`)
119  err.code = 'EINVALIDTAGNAME'
120  return err
121}
122
123function invalidPurlType (type, raw) {
124  // eslint-disable-next-line max-len
125  const err = new Error(`Invalid type "${type}" of package "${raw}": Purl can only be generated for "version" types.`)
126  err.code = 'EINVALIDPURLTYPE'
127  return err
128}
129
130function Result (opts) {
131  this.type = opts.type
132  this.registry = opts.registry
133  this.where = opts.where
134  if (opts.raw == null) {
135    this.raw = opts.name ? opts.name + '@' + opts.rawSpec : opts.rawSpec
136  } else {
137    this.raw = opts.raw
138  }
139
140  this.name = undefined
141  this.escapedName = undefined
142  this.scope = undefined
143  this.rawSpec = opts.rawSpec || ''
144  this.saveSpec = opts.saveSpec
145  this.fetchSpec = opts.fetchSpec
146  if (opts.name) {
147    this.setName(opts.name)
148  }
149  this.gitRange = opts.gitRange
150  this.gitCommittish = opts.gitCommittish
151  this.gitSubdir = opts.gitSubdir
152  this.hosted = opts.hosted
153}
154
155Result.prototype.setName = function (name) {
156  const valid = validatePackageName(name)
157  if (!valid.validForOldPackages) {
158    throw invalidPackageName(name, valid, this.raw)
159  }
160
161  this.name = name
162  this.scope = name[0] === '@' ? name.slice(0, name.indexOf('/')) : undefined
163  // scoped packages in couch must have slash url-encoded, e.g. @foo%2Fbar
164  this.escapedName = name.replace('/', '%2f')
165  return this
166}
167
168Result.prototype.toString = function () {
169  const full = []
170  if (this.name != null && this.name !== '') {
171    full.push(this.name)
172  }
173  const spec = this.saveSpec || this.fetchSpec || this.rawSpec
174  if (spec != null && spec !== '') {
175    full.push(spec)
176  }
177  return full.length ? full.join('@') : this.raw
178}
179
180Result.prototype.toJSON = function () {
181  const result = Object.assign({}, this)
182  delete result.hosted
183  return result
184}
185
186function setGitCommittish (res, committish) {
187  if (!committish) {
188    res.gitCommittish = null
189    return res
190  }
191
192  // for each :: separated item:
193  for (const part of committish.split('::')) {
194    // if the item has no : the n it is a commit-ish
195    if (!part.includes(':')) {
196      if (res.gitRange) {
197        throw new Error('cannot override existing semver range with a committish')
198      }
199      if (res.gitCommittish) {
200        throw new Error('cannot override existing committish with a second committish')
201      }
202      res.gitCommittish = part
203      continue
204    }
205    // split on name:value
206    const [name, value] = part.split(':')
207    // if name is semver do semver lookup of ref or tag
208    if (name === 'semver') {
209      if (res.gitCommittish) {
210        throw new Error('cannot override existing committish with a semver range')
211      }
212      if (res.gitRange) {
213        throw new Error('cannot override existing semver range with a second semver range')
214      }
215      res.gitRange = decodeURIComponent(value)
216      continue
217    }
218    if (name === 'path') {
219      if (res.gitSubdir) {
220        throw new Error('cannot override existing path with a second path')
221      }
222      res.gitSubdir = `/${value}`
223      continue
224    }
225    log.warn('npm-package-arg', `ignoring unknown key "${name}"`)
226  }
227
228  return res
229}
230
231function fromFile (res, where) {
232  if (!where) {
233    where = process.cwd()
234  }
235  res.type = isFilename.test(res.rawSpec) ? 'file' : 'directory'
236  res.where = where
237
238  // always put the '/' on where when resolving urls, or else
239  // file:foo from /path/to/bar goes to /path/to/foo, when we want
240  // it to be /path/to/bar/foo
241
242  let specUrl
243  let resolvedUrl
244  const prefix = (!/^file:/.test(res.rawSpec) ? 'file:' : '')
245  const rawWithPrefix = prefix + res.rawSpec
246  let rawNoPrefix = rawWithPrefix.replace(/^file:/, '')
247  try {
248    resolvedUrl = new url.URL(rawWithPrefix, `file://${path.resolve(where)}/`)
249    specUrl = new url.URL(rawWithPrefix)
250  } catch (originalError) {
251    const er = new Error('Invalid file: URL, must comply with RFC 8909')
252    throw Object.assign(er, {
253      raw: res.rawSpec,
254      spec: res,
255      where,
256      originalError,
257    })
258  }
259
260  // environment switch for testing
261  if (process.env.NPM_PACKAGE_ARG_8909_STRICT !== '1') {
262    // XXX backwards compatibility lack of compliance with 8909
263    // Remove when we want a breaking change to come into RFC compliance.
264    if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
265      const rawSpec = res.rawSpec.replace(/^file:\/\//, 'file:///')
266      resolvedUrl = new url.URL(rawSpec, `file://${path.resolve(where)}/`)
267      specUrl = new url.URL(rawSpec)
268      rawNoPrefix = rawSpec.replace(/^file:/, '')
269    }
270    // turn file:/../foo into file:../foo
271    // for 1, 2 or 3 leading slashes since we attempted
272    // in the previous step to make it a file protocol url with a leading slash
273    if (/^\/{1,3}\.\.?(\/|$)/.test(rawNoPrefix)) {
274      const rawSpec = res.rawSpec.replace(/^file:\/{1,3}/, 'file:')
275      resolvedUrl = new url.URL(rawSpec, `file://${path.resolve(where)}/`)
276      specUrl = new url.URL(rawSpec)
277      rawNoPrefix = rawSpec.replace(/^file:/, '')
278    }
279    // XXX end 8909 violation backwards compatibility section
280  }
281
282  // file:foo - relative url to ./foo
283  // file:/foo - absolute path /foo
284  // file:///foo - absolute path to /foo, no authority host
285  // file://localhost/foo - absolute path to /foo, on localhost
286  // file://foo - absolute path to / on foo host (error!)
287  if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
288    const msg = `Invalid file: URL, must be absolute if // present`
289    throw Object.assign(new Error(msg), {
290      raw: res.rawSpec,
291      parsed: resolvedUrl,
292    })
293  }
294
295  // turn /C:/blah into just C:/blah on windows
296  let specPath = decodeURIComponent(specUrl.pathname)
297  let resolvedPath = decodeURIComponent(resolvedUrl.pathname)
298  if (isWindows) {
299    specPath = specPath.replace(/^\/+([a-z]:\/)/i, '$1')
300    resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, '$1')
301  }
302
303  // replace ~ with homedir, but keep the ~ in the saveSpec
304  // otherwise, make it relative to where param
305  if (/^\/~(\/|$)/.test(specPath)) {
306    res.saveSpec = `file:${specPath.substr(1)}`
307    resolvedPath = path.resolve(homedir(), specPath.substr(3))
308  } else if (!path.isAbsolute(rawNoPrefix)) {
309    res.saveSpec = `file:${path.relative(where, resolvedPath)}`
310  } else {
311    res.saveSpec = `file:${path.resolve(resolvedPath)}`
312  }
313
314  res.fetchSpec = path.resolve(where, resolvedPath)
315  return res
316}
317
318function fromHostedGit (res, hosted) {
319  res.type = 'git'
320  res.hosted = hosted
321  res.saveSpec = hosted.toString({ noGitPlus: false, noCommittish: false })
322  res.fetchSpec = hosted.getDefaultRepresentation() === 'shortcut' ? null : hosted.toString()
323  return setGitCommittish(res, hosted.committish)
324}
325
326function unsupportedURLType (protocol, spec) {
327  const err = new Error(`Unsupported URL Type "${protocol}": ${spec}`)
328  err.code = 'EUNSUPPORTEDPROTOCOL'
329  return err
330}
331
332function matchGitScp (spec) {
333  // git ssh specifiers are overloaded to also use scp-style git
334  // specifiers, so we have to parse those out and treat them special.
335  // They are NOT true URIs, so we can't hand them to `url.parse`.
336  //
337  // This regex looks for things that look like:
338  // git+ssh://git@my.custom.git.com:username/project.git#deadbeef
339  //
340  // ...and various combinations. The username in the beginning is *required*.
341  const matched = spec.match(/^git\+ssh:\/\/([^:#]+:[^#]+(?:\.git)?)(?:#(.*))?$/i)
342  return matched && !matched[1].match(/:[0-9]+\/?.*$/i) && {
343    fetchSpec: matched[1],
344    gitCommittish: matched[2] == null ? null : matched[2],
345  }
346}
347
348function fromURL (res) {
349  // eslint-disable-next-line node/no-deprecated-api
350  const urlparse = url.parse(res.rawSpec)
351  res.saveSpec = res.rawSpec
352  // check the protocol, and then see if it's git or not
353  switch (urlparse.protocol) {
354    case 'git:':
355    case 'git+http:':
356    case 'git+https:':
357    case 'git+rsync:':
358    case 'git+ftp:':
359    case 'git+file:':
360    case 'git+ssh:': {
361      res.type = 'git'
362      const match = urlparse.protocol === 'git+ssh:' ? matchGitScp(res.rawSpec)
363        : null
364      if (match) {
365        setGitCommittish(res, match.gitCommittish)
366        res.fetchSpec = match.fetchSpec
367      } else {
368        setGitCommittish(res, urlparse.hash != null ? urlparse.hash.slice(1) : '')
369        urlparse.protocol = urlparse.protocol.replace(/^git[+]/, '')
370        if (urlparse.protocol === 'file:' && /^git\+file:\/\/[a-z]:/i.test(res.rawSpec)) {
371          // keep the drive letter : on windows file paths
372          urlparse.host += ':'
373          urlparse.hostname += ':'
374        }
375        delete urlparse.hash
376        res.fetchSpec = url.format(urlparse)
377      }
378      break
379    }
380    case 'http:':
381    case 'https:':
382      res.type = 'remote'
383      res.fetchSpec = res.saveSpec
384      break
385
386    default:
387      throw unsupportedURLType(urlparse.protocol, res.rawSpec)
388  }
389
390  return res
391}
392
393function fromAlias (res, where) {
394  const subSpec = npa(res.rawSpec.substr(4), where)
395  if (subSpec.type === 'alias') {
396    throw new Error('nested aliases not supported')
397  }
398
399  if (!subSpec.registry) {
400    throw new Error('aliases only work for registry deps')
401  }
402
403  res.subSpec = subSpec
404  res.registry = true
405  res.type = 'alias'
406  res.saveSpec = null
407  res.fetchSpec = null
408  return res
409}
410
411function fromRegistry (res) {
412  res.registry = true
413  const spec = res.rawSpec.trim()
414  // no save spec for registry components as we save based on the fetched
415  // version, not on the argument so this can't compute that.
416  res.saveSpec = null
417  res.fetchSpec = spec
418  const version = semver.valid(spec, true)
419  const range = semver.validRange(spec, true)
420  if (version) {
421    res.type = 'version'
422  } else if (range) {
423    res.type = 'range'
424  } else {
425    if (encodeURIComponent(spec) !== spec) {
426      throw invalidTagName(spec, res.raw)
427    }
428    res.type = 'tag'
429  }
430  return res
431}
432