123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- 'use strict'
- const isWindows = process.platform === 'win32'
- const { URL } = require('node:url')
- // 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.
- const path = isWindows ? require('node:path/win32') : require('node:path')
- const { homedir } = require('node:os')
- const HostedGit = require('hosted-git-info')
- const semver = require('semver')
- const validatePackageName = require('validate-npm-package-name')
- const { log } = require('proc-log')
- const hasSlashes = isWindows ? /\\|[/]/ : /[/]/
- const isURL = /^(?:git[+])?[a-z]+:/i
- const isGit = /^[^@]+@[^:.]+\.[^:]+:.+$/i
- const isFileType = /[.](?:tgz|tar.gz|tar)$/i
- const isPortNumber = /:[0-9]+(\/|$)/i
- const isWindowsFile = /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/
- const isPosixFile = /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
- const defaultRegistry = 'https://registry.npmjs.org'
- function npa (arg, where) {
- let name
- let spec
- if (typeof arg === 'object') {
- if (arg instanceof Result && (!where || where === arg.where)) {
- return arg
- } else if (arg.name && arg.rawSpec) {
- return npa.resolve(arg.name, arg.rawSpec, where || arg.where)
- } else {
- return npa(arg.raw, where || arg.where)
- }
- }
- const nameEndsAt = arg.indexOf('@', 1) // Skip possible leading @
- const namePart = nameEndsAt > 0 ? arg.slice(0, nameEndsAt) : arg
- if (isURL.test(arg)) {
- spec = arg
- } else if (isGit.test(arg)) {
- spec = `git+ssh://${arg}`
- // eslint-disable-next-line max-len
- } else if (!namePart.startsWith('@') && (hasSlashes.test(namePart) || isFileType.test(namePart))) {
- spec = arg
- } else if (nameEndsAt > 0) {
- name = namePart
- spec = arg.slice(nameEndsAt + 1) || '*'
- } else {
- const valid = validatePackageName(arg)
- if (valid.validForOldPackages) {
- name = arg
- spec = '*'
- } else {
- spec = arg
- }
- }
- return resolve(name, spec, where, arg)
- }
- function isFileSpec (spec) {
- if (!spec) {
- return false
- }
- if (spec.toLowerCase().startsWith('file:')) {
- return true
- }
- if (isWindows) {
- return isWindowsFile.test(spec)
- }
- // We never hit this in windows tests, obviously
- /* istanbul ignore next */
- return isPosixFile.test(spec)
- }
- function isAliasSpec (spec) {
- if (!spec) {
- return false
- }
- return spec.toLowerCase().startsWith('npm:')
- }
- function resolve (name, spec, where, arg) {
- const res = new Result({
- raw: arg,
- name: name,
- rawSpec: spec,
- fromArgument: arg != null,
- })
- if (name) {
- res.name = name
- }
- if (!where) {
- where = process.cwd()
- }
- if (isFileSpec(spec)) {
- return fromFile(res, where)
- } else if (isAliasSpec(spec)) {
- return fromAlias(res, where)
- }
- const hosted = HostedGit.fromUrl(spec, {
- noGitPlus: true,
- noCommittish: true,
- })
- if (hosted) {
- return fromHostedGit(res, hosted)
- } else if (spec && isURL.test(spec)) {
- return fromURL(res)
- } else if (spec && (hasSlashes.test(spec) || isFileType.test(spec))) {
- return fromFile(res, where)
- } else {
- return fromRegistry(res)
- }
- }
- function toPurl (arg, reg = defaultRegistry) {
- const res = npa(arg)
- if (res.type !== 'version') {
- throw invalidPurlType(res.type, res.raw)
- }
- // URI-encode leading @ of scoped packages
- let purl = 'pkg:npm/' + res.name.replace(/^@/, '%40') + '@' + res.rawSpec
- if (reg !== defaultRegistry) {
- purl += '?repository_url=' + reg
- }
- return purl
- }
- function invalidPackageName (name, valid, raw) {
- // eslint-disable-next-line max-len
- const err = new Error(`Invalid package name "${name}" of package "${raw}": ${valid.errors.join('; ')}.`)
- err.code = 'EINVALIDPACKAGENAME'
- return err
- }
- function invalidTagName (name, raw) {
- // eslint-disable-next-line max-len
- const err = new Error(`Invalid tag name "${name}" of package "${raw}": Tags may not have any characters that encodeURIComponent encodes.`)
- err.code = 'EINVALIDTAGNAME'
- return err
- }
- function invalidPurlType (type, raw) {
- // eslint-disable-next-line max-len
- const err = new Error(`Invalid type "${type}" of package "${raw}": Purl can only be generated for "version" types.`)
- err.code = 'EINVALIDPURLTYPE'
- return err
- }
- class Result {
- constructor (opts) {
- this.type = opts.type
- this.registry = opts.registry
- this.where = opts.where
- if (opts.raw == null) {
- this.raw = opts.name ? `${opts.name}@${opts.rawSpec}` : opts.rawSpec
- } else {
- this.raw = opts.raw
- }
- this.name = undefined
- this.escapedName = undefined
- this.scope = undefined
- this.rawSpec = opts.rawSpec || ''
- this.saveSpec = opts.saveSpec
- this.fetchSpec = opts.fetchSpec
- if (opts.name) {
- this.setName(opts.name)
- }
- this.gitRange = opts.gitRange
- this.gitCommittish = opts.gitCommittish
- this.gitSubdir = opts.gitSubdir
- this.hosted = opts.hosted
- }
- // TODO move this to a getter/setter in a semver major
- setName (name) {
- const valid = validatePackageName(name)
- if (!valid.validForOldPackages) {
- throw invalidPackageName(name, valid, this.raw)
- }
- this.name = name
- this.scope = name[0] === '@' ? name.slice(0, name.indexOf('/')) : undefined
- // scoped packages in couch must have slash url-encoded, e.g. @foo%2Fbar
- this.escapedName = name.replace('/', '%2f')
- return this
- }
- toString () {
- const full = []
- if (this.name != null && this.name !== '') {
- full.push(this.name)
- }
- const spec = this.saveSpec || this.fetchSpec || this.rawSpec
- if (spec != null && spec !== '') {
- full.push(spec)
- }
- return full.length ? full.join('@') : this.raw
- }
- toJSON () {
- const result = Object.assign({}, this)
- delete result.hosted
- return result
- }
- }
- // sets res.gitCommittish, res.gitRange, and res.gitSubdir
- function setGitAttrs (res, committish) {
- if (!committish) {
- res.gitCommittish = null
- return
- }
- // for each :: separated item:
- for (const part of committish.split('::')) {
- // if the item has no : the n it is a commit-ish
- if (!part.includes(':')) {
- if (res.gitRange) {
- throw new Error('cannot override existing semver range with a committish')
- }
- if (res.gitCommittish) {
- throw new Error('cannot override existing committish with a second committish')
- }
- res.gitCommittish = part
- continue
- }
- // split on name:value
- const [name, value] = part.split(':')
- // if name is semver do semver lookup of ref or tag
- if (name === 'semver') {
- if (res.gitCommittish) {
- throw new Error('cannot override existing committish with a semver range')
- }
- if (res.gitRange) {
- throw new Error('cannot override existing semver range with a second semver range')
- }
- res.gitRange = decodeURIComponent(value)
- continue
- }
- if (name === 'path') {
- if (res.gitSubdir) {
- throw new Error('cannot override existing path with a second path')
- }
- res.gitSubdir = `/${value}`
- continue
- }
- log.warn('npm-package-arg', `ignoring unknown key "${name}"`)
- }
- }
- // Taken from: EncodePathChars and lookup_table in src/node_url.cc
- // url.pathToFileURL only returns absolute references. We can't use it to encode paths.
- // encodeURI mangles windows paths. We can't use it to encode paths.
- // Under the hood, url.pathToFileURL does a limited set of encoding, with an extra windows step, and then calls path.resolve.
- // The encoding node does without path.resolve is not available outside of the source, so we are recreating it here.
- const encodedPathChars = new Map([
- ['\0', '%00'],
- ['\t', '%09'],
- ['\n', '%0A'],
- ['\r', '%0D'],
- [' ', '%20'],
- ['"', '%22'],
- ['#', '%23'],
- ['%', '%25'],
- ['?', '%3F'],
- ['[', '%5B'],
- ['\\', isWindows ? '/' : '%5C'],
- [']', '%5D'],
- ['^', '%5E'],
- ['|', '%7C'],
- ['~', '%7E'],
- ])
- function pathToFileURL (str) {
- let result = ''
- for (let i = 0; i < str.length; i++) {
- result = `${result}${encodedPathChars.get(str[i]) ?? str[i]}`
- }
- if (result.startsWith('file:')) {
- return result
- }
- return `file:${result}`
- }
- function fromFile (res, where) {
- res.type = isFileType.test(res.rawSpec) ? 'file' : 'directory'
- res.where = where
- let rawSpec = pathToFileURL(res.rawSpec)
- if (rawSpec.startsWith('file:/')) {
- // XXX backwards compatibility lack of compliance with RFC 8089
- // turn file://path into file:/path
- if (/^file:\/\/[^/]/.test(rawSpec)) {
- rawSpec = `file:/${rawSpec.slice(5)}`
- }
- // turn file:/../path into file:../path
- // for 1 or 3 leading slashes (2 is already ruled out from handling file:// explicitly above)
- if (/^\/{1,3}\.\.?(\/|$)/.test(rawSpec.slice(5))) {
- rawSpec = rawSpec.replace(/^file:\/{1,3}/, 'file:')
- }
- }
- let resolvedUrl
- let specUrl
- try {
- // 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
- resolvedUrl = new URL(rawSpec, `${pathToFileURL(path.resolve(where))}/`)
- specUrl = new URL(rawSpec)
- } catch (originalError) {
- const er = new Error('Invalid file: URL, must comply with RFC 8089')
- throw Object.assign(er, {
- raw: res.rawSpec,
- spec: res,
- where,
- originalError,
- })
- }
- // turn /C:/blah into just C:/blah on windows
- let specPath = decodeURIComponent(specUrl.pathname)
- let resolvedPath = decodeURIComponent(resolvedUrl.pathname)
- if (isWindows) {
- specPath = specPath.replace(/^\/+([a-z]:\/)/i, '$1')
- resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, '$1')
- }
- // replace ~ with homedir, but keep the ~ in the saveSpec
- // otherwise, make it relative to where param
- if (/^\/~(\/|$)/.test(specPath)) {
- res.saveSpec = `file:${specPath.substr(1)}`
- resolvedPath = path.resolve(homedir(), specPath.substr(3))
- } else if (!path.isAbsolute(rawSpec.slice(5))) {
- res.saveSpec = `file:${path.relative(where, resolvedPath)}`
- } else {
- res.saveSpec = `file:${path.resolve(resolvedPath)}`
- }
- res.fetchSpec = path.resolve(where, resolvedPath)
- // re-normalize the slashes in saveSpec due to node:path/win32 behavior in windows
- res.saveSpec = res.saveSpec.split('\\').join('/')
- // Ignoring because this only happens in windows
- /* istanbul ignore next */
- if (res.saveSpec.startsWith('file://')) {
- // normalization of \\win32\root paths can cause a double / which we don't want
- res.saveSpec = `file:/${res.saveSpec.slice(7)}`
- }
- return res
- }
- function fromHostedGit (res, hosted) {
- res.type = 'git'
- res.hosted = hosted
- res.saveSpec = hosted.toString({ noGitPlus: false, noCommittish: false })
- res.fetchSpec = hosted.getDefaultRepresentation() === 'shortcut' ? null : hosted.toString()
- setGitAttrs(res, hosted.committish)
- return res
- }
- function unsupportedURLType (protocol, spec) {
- const err = new Error(`Unsupported URL Type "${protocol}": ${spec}`)
- err.code = 'EUNSUPPORTEDPROTOCOL'
- return err
- }
- function fromURL (res) {
- let rawSpec = res.rawSpec
- res.saveSpec = rawSpec
- if (rawSpec.startsWith('git+ssh:')) {
- // git ssh specifiers are overloaded to also use scp-style git
- // specifiers, so we have to parse those out and treat them special.
- // They are NOT true URIs, so we can't hand them to URL.
- // This regex looks for things that look like:
- // git+ssh://git@my.custom.git.com:username/project.git#deadbeef
- // ...and various combinations. The username in the beginning is *required*.
- const matched = rawSpec.match(/^git\+ssh:\/\/([^:#]+:[^#]+(?:\.git)?)(?:#(.*))?$/i)
- // Filter out all-number "usernames" which are really port numbers
- // They can either be :1234 :1234/ or :1234/path but not :12abc
- if (matched && !matched[1].match(isPortNumber)) {
- res.type = 'git'
- setGitAttrs(res, matched[2])
- res.fetchSpec = matched[1]
- return res
- }
- } else if (rawSpec.startsWith('git+file://')) {
- // URL can't handle windows paths
- rawSpec = rawSpec.replace(/\\/g, '/')
- }
- const parsedUrl = new URL(rawSpec)
- // check the protocol, and then see if it's git or not
- switch (parsedUrl.protocol) {
- case 'git:':
- case 'git+http:':
- case 'git+https:':
- case 'git+rsync:':
- case 'git+ftp:':
- case 'git+file:':
- case 'git+ssh:':
- res.type = 'git'
- setGitAttrs(res, parsedUrl.hash.slice(1))
- if (parsedUrl.protocol === 'git+file:' && /^git\+file:\/\/[a-z]:/i.test(rawSpec)) {
- // URL can't handle drive letters on windows file paths, the host can't contain a :
- res.fetchSpec = `git+file://${parsedUrl.host.toLowerCase()}:${parsedUrl.pathname}`
- } else {
- parsedUrl.hash = ''
- res.fetchSpec = parsedUrl.toString()
- }
- if (res.fetchSpec.startsWith('git+')) {
- res.fetchSpec = res.fetchSpec.slice(4)
- }
- break
- case 'http:':
- case 'https:':
- res.type = 'remote'
- res.fetchSpec = res.saveSpec
- break
- default:
- throw unsupportedURLType(parsedUrl.protocol, rawSpec)
- }
- return res
- }
- function fromAlias (res, where) {
- const subSpec = npa(res.rawSpec.substr(4), where)
- if (subSpec.type === 'alias') {
- throw new Error('nested aliases not supported')
- }
- if (!subSpec.registry) {
- throw new Error('aliases only work for registry deps')
- }
- if (!subSpec.name) {
- throw new Error('aliases must have a name')
- }
- res.subSpec = subSpec
- res.registry = true
- res.type = 'alias'
- res.saveSpec = null
- res.fetchSpec = null
- return res
- }
- function fromRegistry (res) {
- res.registry = true
- const spec = res.rawSpec.trim()
- // no save spec for registry components as we save based on the fetched
- // version, not on the argument so this can't compute that.
- res.saveSpec = null
- res.fetchSpec = spec
- const version = semver.valid(spec, true)
- const range = semver.validRange(spec, true)
- if (version) {
- res.type = 'version'
- } else if (range) {
- res.type = 'range'
- } else {
- if (encodeURIComponent(spec) !== spec) {
- throw invalidTagName(spec, res.raw)
- }
- res.type = 'tag'
- }
- return res
- }
- module.exports = npa
- module.exports.resolve = resolve
- module.exports.toPurl = toPurl
- module.exports.Result = Result
|