| 'use strict' |
| |
| /** @type {(value: string) => boolean} */ |
| const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu) |
| |
| /** @type {(value: string) => boolean} */ |
| 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) |
| |
| /** @type {(value: string) => boolean} */ |
| const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu) |
| |
| /** @type {(value: string) => boolean} */ |
| const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu) |
| |
| /** @type {(value: string) => boolean} */ |
| const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu) |
| |
| /** |
| * @param {Array<string>} input |
| * @returns {string} |
| */ |
| function stringArrayToHexStripped (input) { |
| let acc = '' |
| let code = 0 |
| let i = 0 |
| |
| for (i = 0; i < input.length; i++) { |
| code = input[i].charCodeAt(0) |
| if (code === 48) { |
| continue |
| } |
| if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102))) { |
| return '' |
| } |
| acc += input[i] |
| break |
| } |
| |
| for (i += 1; i < input.length; i++) { |
| code = input[i].charCodeAt(0) |
| if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102))) { |
| return '' |
| } |
| acc += input[i] |
| } |
| return acc |
| } |
| |
| /** |
| * @typedef {Object} GetIPV6Result |
| * @property {boolean} error - Indicates if there was an error parsing the IPv6 address. |
| * @property {string} address - The parsed IPv6 address. |
| * @property {string} [zone] - The zone identifier, if present. |
| */ |
| |
| /** |
| * @param {string} value |
| * @returns {boolean} |
| */ |
| const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u) |
| |
| /** |
| * @param {Array<string>} buffer |
| * @returns {boolean} |
| */ |
| function consumeIsZone (buffer) { |
| buffer.length = 0 |
| return true |
| } |
| |
| /** |
| * @param {Array<string>} buffer |
| * @param {Array<string>} address |
| * @param {GetIPV6Result} output |
| * @returns {boolean} |
| */ |
| function consumeHextets (buffer, address, output) { |
| if (buffer.length) { |
| const hex = stringArrayToHexStripped(buffer) |
| if (hex !== '') { |
| address.push(hex) |
| } else { |
| output.error = true |
| return false |
| } |
| buffer.length = 0 |
| } |
| return true |
| } |
| |
| /** |
| * @param {string} input |
| * @returns {GetIPV6Result} |
| */ |
| function getIPV6 (input) { |
| let tokenCount = 0 |
| const output = { error: false, address: '', zone: '' } |
| /** @type {Array<string>} */ |
| const address = [] |
| /** @type {Array<string>} */ |
| const buffer = [] |
| let endipv6Encountered = false |
| let endIpv6 = false |
| |
| let consume = consumeHextets |
| |
| for (let i = 0; i < input.length; i++) { |
| const cursor = input[i] |
| if (cursor === '[' || cursor === ']') { continue } |
| if (cursor === ':') { |
| if (endipv6Encountered === true) { |
| endIpv6 = true |
| } |
| if (!consume(buffer, address, output)) { break } |
| if (++tokenCount > 7) { |
| // not valid |
| output.error = true |
| break |
| } |
| if (i > 0 && input[i - 1] === ':') { |
| endipv6Encountered = true |
| } |
| address.push(':') |
| continue |
| } else if (cursor === '%') { |
| if (!consume(buffer, address, output)) { break } |
| // switch to zone detection |
| consume = consumeIsZone |
| } else { |
| buffer.push(cursor) |
| continue |
| } |
| } |
| if (buffer.length) { |
| if (consume === consumeIsZone) { |
| output.zone = buffer.join('') |
| } else if (endIpv6) { |
| address.push(buffer.join('')) |
| } else { |
| address.push(stringArrayToHexStripped(buffer)) |
| } |
| } |
| output.address = address.join('') |
| return output |
| } |
| |
| /** |
| * @typedef {Object} NormalizeIPv6Result |
| * @property {string} host - The normalized host. |
| * @property {string} [escapedHost] - The escaped host. |
| * @property {boolean} isIPV6 - Indicates if the host is an IPv6 address. |
| */ |
| |
| /** |
| * @param {string} host |
| * @returns {NormalizeIPv6Result} |
| */ |
| function normalizeIPv6 (host) { |
| if (findToken(host, ':') < 2) { return { host, isIPV6: false } } |
| const ipv6 = getIPV6(host) |
| |
| if (!ipv6.error) { |
| let newHost = ipv6.address |
| let escapedHost = ipv6.address |
| if (ipv6.zone) { |
| newHost += '%' + ipv6.zone |
| escapedHost += '%25' + ipv6.zone |
| } |
| return { host: newHost, isIPV6: true, escapedHost } |
| } else { |
| return { host, isIPV6: false } |
| } |
| } |
| |
| /** |
| * @param {string} str |
| * @param {string} token |
| * @returns {number} |
| */ |
| function findToken (str, token) { |
| let ind = 0 |
| for (let i = 0; i < str.length; i++) { |
| if (str[i] === token) ind++ |
| } |
| return ind |
| } |
| |
| /** |
| * @param {string} path |
| * @returns {string} |
| * |
| * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4 |
| */ |
| function removeDotSegments (path) { |
| let input = path |
| const output = [] |
| let nextSlash = -1 |
| let len = 0 |
| |
| // eslint-disable-next-line no-cond-assign |
| while (len = input.length) { |
| if (len === 1) { |
| if (input === '.') { |
| break |
| } else if (input === '/') { |
| output.push('/') |
| break |
| } else { |
| output.push(input) |
| break |
| } |
| } else if (len === 2) { |
| if (input[0] === '.') { |
| if (input[1] === '.') { |
| break |
| } else if (input[1] === '/') { |
| input = input.slice(2) |
| continue |
| } |
| } else if (input[0] === '/') { |
| if (input[1] === '.' || input[1] === '/') { |
| output.push('/') |
| break |
| } |
| } |
| } else if (len === 3) { |
| if (input === '/..') { |
| if (output.length !== 0) { |
| output.pop() |
| } |
| output.push('/') |
| break |
| } |
| } |
| if (input[0] === '.') { |
| if (input[1] === '.') { |
| if (input[2] === '/') { |
| input = input.slice(3) |
| continue |
| } |
| } else if (input[1] === '/') { |
| input = input.slice(2) |
| continue |
| } |
| } else if (input[0] === '/') { |
| if (input[1] === '.') { |
| if (input[2] === '/') { |
| input = input.slice(2) |
| continue |
| } else if (input[2] === '.') { |
| if (input[3] === '/') { |
| input = input.slice(3) |
| if (output.length !== 0) { |
| output.pop() |
| } |
| continue |
| } |
| } |
| } |
| } |
| |
| // Rule 2E: Move normal path segment to output |
| if ((nextSlash = input.indexOf('/', 1)) === -1) { |
| output.push(input) |
| break |
| } else { |
| output.push(input.slice(0, nextSlash)) |
| input = input.slice(nextSlash) |
| } |
| } |
| |
| return output.join('') |
| } |
| |
| /** |
| * Re-escape RFC 3986 gen-delims that must not appear literally in the host. |
| * After the URI regex parses, these characters cannot be literal in the host |
| * field, so any that appear after decoding came from percent-encoding and |
| * must be restored to prevent authority structure changes. |
| * |
| * @param {string} host |
| * @param {boolean} isIP - true for IPv4/IPv6 hosts (skip colon re-escaping) |
| * @returns {string} |
| */ |
| const HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23', ':': '%3A' } |
| const HOST_DELIM_RE = /[@/?#:]/g |
| const HOST_DELIM_NO_COLON_RE = /[@/?#]/g |
| |
| function reescapeHostDelimiters (host, isIP) { |
| const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE |
| re.lastIndex = 0 |
| return host.replace(re, (ch) => HOST_DELIMS[ch]) |
| } |
| |
| /** |
| * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes. |
| * Reserved delimiters such as `%2F` and `%2E` stay escaped. |
| * |
| * @param {string} input |
| * @param {boolean} [decodeUnreserved=false] |
| * @returns {string} |
| */ |
| function normalizePercentEncoding (input, decodeUnreserved = false) { |
| if (input.indexOf('%') === -1) { |
| return input |
| } |
| |
| let output = '' |
| |
| for (let i = 0; i < input.length; i++) { |
| if (input[i] === '%' && i + 2 < input.length) { |
| const hex = input.slice(i + 1, i + 3) |
| if (isHexPair(hex)) { |
| const normalizedHex = hex.toUpperCase() |
| const decoded = String.fromCharCode(parseInt(normalizedHex, 16)) |
| |
| if (decodeUnreserved && isUnreserved(decoded)) { |
| output += decoded |
| } else { |
| output += '%' + normalizedHex |
| } |
| |
| i += 2 |
| continue |
| } |
| } |
| |
| output += input[i] |
| } |
| |
| return output |
| } |
| |
| /** |
| * Normalizes path data without turning reserved escapes into live path syntax. |
| * Valid escapes are uppercased, raw unsafe characters are escaped, and only |
| * unreserved bytes that are not `.` are decoded. |
| * |
| * @param {string} input |
| * @returns {string} |
| */ |
| function normalizePathEncoding (input) { |
| let output = '' |
| |
| for (let i = 0; i < input.length; i++) { |
| if (input[i] === '%' && i + 2 < input.length) { |
| const hex = input.slice(i + 1, i + 3) |
| if (isHexPair(hex)) { |
| const normalizedHex = hex.toUpperCase() |
| const decoded = String.fromCharCode(parseInt(normalizedHex, 16)) |
| |
| if (decoded !== '.' && isUnreserved(decoded)) { |
| output += decoded |
| } else { |
| output += '%' + normalizedHex |
| } |
| |
| i += 2 |
| continue |
| } |
| } |
| |
| if (isPathCharacter(input[i])) { |
| output += input[i] |
| } else { |
| output += escape(input[i]) |
| } |
| } |
| |
| return output |
| } |
| |
| /** |
| * Escapes a component while preserving existing valid percent escapes. |
| * |
| * @param {string} input |
| * @returns {string} |
| */ |
| function escapePreservingEscapes (input) { |
| let output = '' |
| |
| for (let i = 0; i < input.length; i++) { |
| if (input[i] === '%' && i + 2 < input.length) { |
| const hex = input.slice(i + 1, i + 3) |
| if (isHexPair(hex)) { |
| output += '%' + hex.toUpperCase() |
| i += 2 |
| continue |
| } |
| } |
| |
| output += escape(input[i]) |
| } |
| |
| return output |
| } |
| |
| /** |
| * @param {import('../types/index').URIComponent} component |
| * @returns {string|undefined} |
| */ |
| function recomposeAuthority (component) { |
| const uriTokens = [] |
| |
| if (component.userinfo !== undefined) { |
| uriTokens.push(component.userinfo) |
| uriTokens.push('@') |
| } |
| |
| if (component.host !== undefined) { |
| let host = unescape(component.host) |
| if (!isIPv4(host)) { |
| const ipV6res = normalizeIPv6(host) |
| if (ipV6res.isIPV6 === true) { |
| host = `[${ipV6res.escapedHost}]` |
| } else { |
| host = reescapeHostDelimiters(host, false) |
| } |
| } |
| uriTokens.push(host) |
| } |
| |
| if (typeof component.port === 'number' || typeof component.port === 'string') { |
| uriTokens.push(':') |
| uriTokens.push(String(component.port)) |
| } |
| |
| return uriTokens.length ? uriTokens.join('') : undefined |
| }; |
| |
| module.exports = { |
| nonSimpleDomain, |
| recomposeAuthority, |
| reescapeHostDelimiters, |
| normalizePercentEncoding, |
| normalizePathEncoding, |
| escapePreservingEscapes, |
| removeDotSegments, |
| isIPv4, |
| isUUID, |
| normalizeIPv6, |
| stringArrayToHexStripped |
| } |