npa.js 13 KB

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