npa.js 14 KB

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