utils.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. 'use strict'
  2. /** @type {(value: string) => boolean} */
  3. const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu)
  4. /** @type {(value: string) => boolean} */
  5. const isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u)
  6. /** @type {(value: string) => boolean} */
  7. const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
  8. /** @type {(value: string) => boolean} */
  9. const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
  10. /** @type {(value: string) => boolean} */
  11. const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
  12. /**
  13. * @param {Array<string>} input
  14. * @returns {string}
  15. */
  16. function stringArrayToHexStripped (input) {
  17. let acc = ''
  18. let code = 0
  19. let i = 0
  20. for (i = 0; i < input.length; i++) {
  21. code = input[i].charCodeAt(0)
  22. if (code === 48) {
  23. continue
  24. }
  25. if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102))) {
  26. return ''
  27. }
  28. acc += input[i]
  29. break
  30. }
  31. for (i += 1; i < input.length; i++) {
  32. code = input[i].charCodeAt(0)
  33. if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102))) {
  34. return ''
  35. }
  36. acc += input[i]
  37. }
  38. return acc
  39. }
  40. /**
  41. * @typedef {Object} GetIPV6Result
  42. * @property {boolean} error - Indicates if there was an error parsing the IPv6 address.
  43. * @property {string} address - The parsed IPv6 address.
  44. * @property {string} [zone] - The zone identifier, if present.
  45. */
  46. /**
  47. * @param {string} value
  48. * @returns {boolean}
  49. */
  50. const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
  51. /**
  52. * @param {Array<string>} buffer
  53. * @returns {boolean}
  54. */
  55. function consumeIsZone (buffer) {
  56. buffer.length = 0
  57. return true
  58. }
  59. /**
  60. * @param {Array<string>} buffer
  61. * @param {Array<string>} address
  62. * @param {GetIPV6Result} output
  63. * @returns {boolean}
  64. */
  65. function consumeHextets (buffer, address, output) {
  66. if (buffer.length) {
  67. const hex = stringArrayToHexStripped(buffer)
  68. if (hex !== '') {
  69. address.push(hex)
  70. } else {
  71. output.error = true
  72. return false
  73. }
  74. buffer.length = 0
  75. }
  76. return true
  77. }
  78. /**
  79. * @param {string} input
  80. * @returns {GetIPV6Result}
  81. */
  82. function getIPV6 (input) {
  83. let tokenCount = 0
  84. const output = { error: false, address: '', zone: '' }
  85. /** @type {Array<string>} */
  86. const address = []
  87. /** @type {Array<string>} */
  88. const buffer = []
  89. let endipv6Encountered = false
  90. let endIpv6 = false
  91. let consume = consumeHextets
  92. for (let i = 0; i < input.length; i++) {
  93. const cursor = input[i]
  94. if (cursor === '[' || cursor === ']') { continue }
  95. if (cursor === ':') {
  96. if (endipv6Encountered === true) {
  97. endIpv6 = true
  98. }
  99. if (!consume(buffer, address, output)) { break }
  100. if (++tokenCount > 7) {
  101. // not valid
  102. output.error = true
  103. break
  104. }
  105. if (i > 0 && input[i - 1] === ':') {
  106. endipv6Encountered = true
  107. }
  108. address.push(':')
  109. continue
  110. } else if (cursor === '%') {
  111. if (!consume(buffer, address, output)) { break }
  112. // switch to zone detection
  113. consume = consumeIsZone
  114. } else {
  115. buffer.push(cursor)
  116. continue
  117. }
  118. }
  119. if (buffer.length) {
  120. if (consume === consumeIsZone) {
  121. output.zone = buffer.join('')
  122. } else if (endIpv6) {
  123. address.push(buffer.join(''))
  124. } else {
  125. address.push(stringArrayToHexStripped(buffer))
  126. }
  127. }
  128. output.address = address.join('')
  129. return output
  130. }
  131. /**
  132. * @typedef {Object} NormalizeIPv6Result
  133. * @property {string} host - The normalized host.
  134. * @property {string} [escapedHost] - The escaped host.
  135. * @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
  136. */
  137. /**
  138. * @param {string} host
  139. * @returns {NormalizeIPv6Result}
  140. */
  141. function normalizeIPv6 (host) {
  142. if (findToken(host, ':') < 2) { return { host, isIPV6: false } }
  143. const ipv6 = getIPV6(host)
  144. if (!ipv6.error) {
  145. let newHost = ipv6.address
  146. let escapedHost = ipv6.address
  147. if (ipv6.zone) {
  148. newHost += '%' + ipv6.zone
  149. escapedHost += '%25' + ipv6.zone
  150. }
  151. return { host: newHost, isIPV6: true, escapedHost }
  152. } else {
  153. return { host, isIPV6: false }
  154. }
  155. }
  156. /**
  157. * @param {string} str
  158. * @param {string} token
  159. * @returns {number}
  160. */
  161. function findToken (str, token) {
  162. let ind = 0
  163. for (let i = 0; i < str.length; i++) {
  164. if (str[i] === token) ind++
  165. }
  166. return ind
  167. }
  168. /**
  169. * @param {string} path
  170. * @returns {string}
  171. *
  172. * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
  173. */
  174. function removeDotSegments (path) {
  175. let input = path
  176. const output = []
  177. let nextSlash = -1
  178. let len = 0
  179. // eslint-disable-next-line no-cond-assign
  180. while (len = input.length) {
  181. if (len === 1) {
  182. if (input === '.') {
  183. break
  184. } else if (input === '/') {
  185. output.push('/')
  186. break
  187. } else {
  188. output.push(input)
  189. break
  190. }
  191. } else if (len === 2) {
  192. if (input[0] === '.') {
  193. if (input[1] === '.') {
  194. break
  195. } else if (input[1] === '/') {
  196. input = input.slice(2)
  197. continue
  198. }
  199. } else if (input[0] === '/') {
  200. if (input[1] === '.' || input[1] === '/') {
  201. output.push('/')
  202. break
  203. }
  204. }
  205. } else if (len === 3) {
  206. if (input === '/..') {
  207. if (output.length !== 0) {
  208. output.pop()
  209. }
  210. output.push('/')
  211. break
  212. }
  213. }
  214. if (input[0] === '.') {
  215. if (input[1] === '.') {
  216. if (input[2] === '/') {
  217. input = input.slice(3)
  218. continue
  219. }
  220. } else if (input[1] === '/') {
  221. input = input.slice(2)
  222. continue
  223. }
  224. } else if (input[0] === '/') {
  225. if (input[1] === '.') {
  226. if (input[2] === '/') {
  227. input = input.slice(2)
  228. continue
  229. } else if (input[2] === '.') {
  230. if (input[3] === '/') {
  231. input = input.slice(3)
  232. if (output.length !== 0) {
  233. output.pop()
  234. }
  235. continue
  236. }
  237. }
  238. }
  239. }
  240. // Rule 2E: Move normal path segment to output
  241. if ((nextSlash = input.indexOf('/', 1)) === -1) {
  242. output.push(input)
  243. break
  244. } else {
  245. output.push(input.slice(0, nextSlash))
  246. input = input.slice(nextSlash)
  247. }
  248. }
  249. return output.join('')
  250. }
  251. /**
  252. * Re-escape RFC 3986 gen-delims that must not appear literally in the host.
  253. * After the URI regex parses, these characters cannot be literal in the host
  254. * field, so any that appear after decoding came from percent-encoding and
  255. * must be restored to prevent authority structure changes.
  256. *
  257. * @param {string} host
  258. * @param {boolean} isIP - true for IPv4/IPv6 hosts (skip colon re-escaping)
  259. * @returns {string}
  260. */
  261. const HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23', ':': '%3A' }
  262. const HOST_DELIM_RE = /[@/?#:]/g
  263. const HOST_DELIM_NO_COLON_RE = /[@/?#]/g
  264. function reescapeHostDelimiters (host, isIP) {
  265. const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE
  266. re.lastIndex = 0
  267. return host.replace(re, (ch) => HOST_DELIMS[ch])
  268. }
  269. /**
  270. * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
  271. * Reserved delimiters such as `%2F` and `%2E` stay escaped.
  272. *
  273. * @param {string} input
  274. * @param {boolean} [decodeUnreserved=false]
  275. * @returns {string}
  276. */
  277. function normalizePercentEncoding (input, decodeUnreserved = false) {
  278. if (input.indexOf('%') === -1) {
  279. return input
  280. }
  281. let output = ''
  282. for (let i = 0; i < input.length; i++) {
  283. if (input[i] === '%' && i + 2 < input.length) {
  284. const hex = input.slice(i + 1, i + 3)
  285. if (isHexPair(hex)) {
  286. const normalizedHex = hex.toUpperCase()
  287. const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
  288. if (decodeUnreserved && isUnreserved(decoded)) {
  289. output += decoded
  290. } else {
  291. output += '%' + normalizedHex
  292. }
  293. i += 2
  294. continue
  295. }
  296. }
  297. output += input[i]
  298. }
  299. return output
  300. }
  301. /**
  302. * Normalizes path data without turning reserved escapes into live path syntax.
  303. * Valid escapes are uppercased, raw unsafe characters are escaped, and only
  304. * unreserved bytes that are not `.` are decoded.
  305. *
  306. * @param {string} input
  307. * @returns {string}
  308. */
  309. function normalizePathEncoding (input) {
  310. let output = ''
  311. for (let i = 0; i < input.length; i++) {
  312. if (input[i] === '%' && i + 2 < input.length) {
  313. const hex = input.slice(i + 1, i + 3)
  314. if (isHexPair(hex)) {
  315. const normalizedHex = hex.toUpperCase()
  316. const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
  317. if (decoded !== '.' && isUnreserved(decoded)) {
  318. output += decoded
  319. } else {
  320. output += '%' + normalizedHex
  321. }
  322. i += 2
  323. continue
  324. }
  325. }
  326. if (isPathCharacter(input[i])) {
  327. output += input[i]
  328. } else {
  329. output += escape(input[i])
  330. }
  331. }
  332. return output
  333. }
  334. /**
  335. * Escapes a component while preserving existing valid percent escapes.
  336. *
  337. * @param {string} input
  338. * @returns {string}
  339. */
  340. function escapePreservingEscapes (input) {
  341. let output = ''
  342. for (let i = 0; i < input.length; i++) {
  343. if (input[i] === '%' && i + 2 < input.length) {
  344. const hex = input.slice(i + 1, i + 3)
  345. if (isHexPair(hex)) {
  346. output += '%' + hex.toUpperCase()
  347. i += 2
  348. continue
  349. }
  350. }
  351. output += escape(input[i])
  352. }
  353. return output
  354. }
  355. /**
  356. * @param {import('../types/index').URIComponent} component
  357. * @returns {string|undefined}
  358. */
  359. function recomposeAuthority (component) {
  360. const uriTokens = []
  361. if (component.userinfo !== undefined) {
  362. uriTokens.push(component.userinfo)
  363. uriTokens.push('@')
  364. }
  365. if (component.host !== undefined) {
  366. let host = unescape(component.host)
  367. if (!isIPv4(host)) {
  368. const ipV6res = normalizeIPv6(host)
  369. if (ipV6res.isIPV6 === true) {
  370. host = `[${ipV6res.escapedHost}]`
  371. } else {
  372. host = reescapeHostDelimiters(host, false)
  373. }
  374. }
  375. uriTokens.push(host)
  376. }
  377. if (typeof component.port === 'number' || typeof component.port === 'string') {
  378. uriTokens.push(':')
  379. uriTokens.push(String(component.port))
  380. }
  381. return uriTokens.length ? uriTokens.join('') : undefined
  382. };
  383. module.exports = {
  384. nonSimpleDomain,
  385. recomposeAuthority,
  386. reescapeHostDelimiters,
  387. normalizePercentEncoding,
  388. normalizePathEncoding,
  389. escapePreservingEscapes,
  390. removeDotSegments,
  391. isIPv4,
  392. isUUID,
  393. normalizeIPv6,
  394. stringArrayToHexStripped
  395. }