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