fast-uri 2.4.4 → 2.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +216 -62
- package/lib/schemes.js +9 -4
- package/lib/utils.js +432 -117
- package/package.json +2 -1
- package/test/compatibility.test.js +12 -0
- package/test/component-safe-serialization.test.js +163 -0
- package/test/equal.test.js +32 -0
- package/test/ipv6-validation.test.js +40 -0
- package/test/malformed-percent.test.js +77 -0
- package/test/malformed-urn.test.js +61 -0
- package/test/parse.test.js +3 -3
- package/test/query-fragment-normalization.test.js +33 -0
- package/test/reserved-path-normalization.test.js +109 -0
- package/test/scheme-validation.test.js +124 -0
- package/test/security-normalization.test.js +102 -2
- package/test/security.test.js +78 -7
- package/test/urn-full-input.test.js +29 -0
- package/test/websocket-query-preservation.test.js +24 -0
package/lib/utils.js
CHANGED
|
@@ -2,16 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const { HEX } = require('./scopedChars')
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return { host: stripLeadingZeros(address, '.'), isIPV4: true }
|
|
11
|
-
} else {
|
|
12
|
-
return { host, isIPV4: false }
|
|
13
|
-
}
|
|
14
|
-
}
|
|
5
|
+
/** @type {(value: string) => boolean} */
|
|
6
|
+
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)
|
|
7
|
+
|
|
8
|
+
/** @type {(value: string) => boolean} */
|
|
9
|
+
const isPort = RegExp.prototype.test.bind(/^\d*$/u)
|
|
15
10
|
|
|
16
11
|
/**
|
|
17
12
|
* @param {string[]} input
|
|
@@ -30,110 +25,172 @@ function stringArrayToHexStripped (input, keepZero = false) {
|
|
|
30
25
|
return acc
|
|
31
26
|
}
|
|
32
27
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const output = { error: false, address: '', zone: '' }
|
|
36
|
-
const address = []
|
|
37
|
-
const buffer = []
|
|
38
|
-
let isZone = false
|
|
39
|
-
let endipv6Encountered = false
|
|
40
|
-
let endIpv6 = false
|
|
41
|
-
|
|
42
|
-
function consume () {
|
|
43
|
-
if (buffer.length) {
|
|
44
|
-
if (isZone === false) {
|
|
45
|
-
const hex = stringArrayToHexStripped(buffer)
|
|
46
|
-
if (hex !== undefined) {
|
|
47
|
-
address.push(hex)
|
|
48
|
-
} else {
|
|
49
|
-
output.error = true
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
buffer.length = 0
|
|
54
|
-
}
|
|
55
|
-
return true
|
|
56
|
-
}
|
|
28
|
+
/** @type {(value: string) => boolean} */
|
|
29
|
+
const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
|
|
57
30
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
buffer.push(cursor)
|
|
31
|
+
/** @type {(value: string) => boolean} */
|
|
32
|
+
const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
|
|
33
|
+
|
|
34
|
+
/** @type {(value: string) => boolean} */
|
|
35
|
+
const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} value
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
/** @type {(value: string) => boolean} */
|
|
42
|
+
const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} zone
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
function isZoneIdentifier (zone) {
|
|
49
|
+
if (zone.length === 0) return false
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < zone.length; i++) {
|
|
52
|
+
if (isZoneCharacter(zone[i])) continue
|
|
53
|
+
if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
|
|
54
|
+
i += 2
|
|
83
55
|
continue
|
|
84
56
|
}
|
|
57
|
+
return false
|
|
85
58
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
59
|
+
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
|
|
65
|
+
* single zero hextet is left uncompressed. On ties the leftmost run wins.
|
|
66
|
+
*
|
|
67
|
+
* @param {string[]} hextets
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function compressIPv6ZeroRun (hextets) {
|
|
71
|
+
let bestStart = -1
|
|
72
|
+
let bestLength = 0
|
|
73
|
+
let runStart = -1
|
|
74
|
+
let runLength = 0
|
|
75
|
+
for (let i = 0; i < hextets.length; i++) {
|
|
76
|
+
if (hextets[i] === '0') {
|
|
77
|
+
if (runStart === -1) runStart = i
|
|
78
|
+
runLength++
|
|
79
|
+
if (runLength > bestLength) {
|
|
80
|
+
bestLength = runLength
|
|
81
|
+
bestStart = runStart
|
|
82
|
+
}
|
|
91
83
|
} else {
|
|
92
|
-
|
|
84
|
+
runStart = -1
|
|
85
|
+
runLength = 0
|
|
93
86
|
}
|
|
94
87
|
}
|
|
95
|
-
|
|
96
|
-
return
|
|
88
|
+
|
|
89
|
+
if (bestLength < 2) return hextets.join(':')
|
|
90
|
+
|
|
91
|
+
const head = hextets.slice(0, bestStart).join(':')
|
|
92
|
+
const tail = hextets.slice(bestStart + bestLength).join(':')
|
|
93
|
+
return head + '::' + tail
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Validates an IPv6 address against the alternatives in RFC 3986 section
|
|
98
|
+
* 3.2.2 and returns the same address with leading hextet zeroes removed.
|
|
99
|
+
* An embedded IPv4 address counts as two hextets and is only valid at the end.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} input
|
|
102
|
+
* @returns {string|undefined}
|
|
103
|
+
*/
|
|
104
|
+
function normalizeIPv6Address (input) {
|
|
105
|
+
const compression = input.indexOf('::')
|
|
106
|
+
if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
|
|
107
|
+
|
|
108
|
+
const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
|
|
109
|
+
const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
|
|
110
|
+
if (compression !== -1) {
|
|
111
|
+
if (left.length === 1 && left[0] === '') left.length = 0
|
|
112
|
+
if (right.length === 1 && right[0] === '') right.length = 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parts = left.concat(right)
|
|
116
|
+
let hextetCount = 0
|
|
117
|
+
for (let i = 0; i < parts.length; i++) {
|
|
118
|
+
const part = parts[i]
|
|
119
|
+
if (part === '') return undefined
|
|
102
120
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
newHost += '%' + ipv6.zone
|
|
108
|
-
escapedHost += '%25' + ipv6.zone
|
|
121
|
+
if (part.indexOf('.') !== -1) {
|
|
122
|
+
if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
|
|
123
|
+
hextetCount += 2
|
|
124
|
+
continue
|
|
109
125
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
|
|
127
|
+
if (!isHextet(part)) return undefined
|
|
128
|
+
parts[i] = parseInt(part, 16).toString(16)
|
|
129
|
+
hextetCount++
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (compression === -1) {
|
|
133
|
+
if (hextetCount !== 8) return undefined
|
|
134
|
+
return compressIPv6ZeroRun(parts)
|
|
113
135
|
}
|
|
136
|
+
if (hextetCount >= 8) return undefined
|
|
137
|
+
|
|
138
|
+
// expand "::" then re-compress the longest run for a canonical result
|
|
139
|
+
const expanded = parts.slice(0, left.length)
|
|
140
|
+
for (let i = hextetCount; i < 8; i++) expanded.push('0')
|
|
141
|
+
for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
|
|
142
|
+
return compressIPv6ZeroRun(expanded)
|
|
114
143
|
}
|
|
115
144
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
145
|
+
/**
|
|
146
|
+
* @typedef {Object} NormalizeIPv6Result
|
|
147
|
+
* @property {string} host - The normalized host.
|
|
148
|
+
* @property {string} [escapedHost] - The escaped host.
|
|
149
|
+
* @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
|
|
150
|
+
* @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
|
|
151
|
+
* @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validates and normalizes a bracketed IP literal. Raw zone separators remain
|
|
156
|
+
* accepted for backwards compatibility, while encoded separators and zone
|
|
157
|
+
* contents follow RFC 6874.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} host
|
|
160
|
+
* @returns {NormalizeIPv6Result}
|
|
161
|
+
*/
|
|
162
|
+
function normalizeIPv6 (host) {
|
|
163
|
+
const bracketed = host[0] === '[' && host[host.length - 1] === ']'
|
|
164
|
+
const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
|
|
165
|
+
if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
|
|
166
|
+
|
|
167
|
+
let input = bracketed ? host.slice(1, -1) : host
|
|
168
|
+
if (bracketed && isIPvFuture(input)) {
|
|
169
|
+
input = input.toLowerCase()
|
|
170
|
+
return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (findToken(input, ':') < 2) {
|
|
174
|
+
return { host, isIPV6: false, error: bracketed }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let zoneIdentifier = ''
|
|
178
|
+
const zoneSeparator = input.indexOf('%')
|
|
179
|
+
if (zoneSeparator !== -1) {
|
|
180
|
+
const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
|
|
181
|
+
zoneIdentifier = input.slice(zoneSeparator + separatorLength)
|
|
182
|
+
if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
|
|
183
|
+
input = input.slice(0, zoneSeparator)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const address = normalizeIPv6Address(input)
|
|
187
|
+
if (address === undefined) return { host, isIPV6: false, error: true }
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
|
|
191
|
+
escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
|
|
192
|
+
isIPV6: true
|
|
135
193
|
}
|
|
136
|
-
return out
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
function findToken (str, token) {
|
|
@@ -198,11 +255,40 @@ function reescapeHostDelimiters (host, isIP) {
|
|
|
198
255
|
|
|
199
256
|
const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
|
|
200
257
|
const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
|
|
201
|
-
const isPathCharacter = RegExp.prototype.test.bind(/^[
|
|
258
|
+
const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u)
|
|
259
|
+
|
|
260
|
+
/** @type {(value: string) => boolean} */
|
|
261
|
+
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u)
|
|
262
|
+
|
|
263
|
+
/** @type {(value: string) => boolean} */
|
|
264
|
+
const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
|
|
265
|
+
|
|
266
|
+
const BYTE_HEX = new Array(256)
|
|
267
|
+
{
|
|
268
|
+
const HEX_DIGITS = '0123456789ABCDEF'
|
|
269
|
+
for (let i = 0; i < 256; i++) {
|
|
270
|
+
BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF]
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function percentEncodeNonAscii (cp) {
|
|
274
|
+
if (cp < 0x800) {
|
|
275
|
+
return BYTE_HEX[0xC0 | (cp >> 6)] +
|
|
276
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
277
|
+
}
|
|
278
|
+
if (cp < 0x10000) {
|
|
279
|
+
return BYTE_HEX[0xE0 | (cp >> 12)] +
|
|
280
|
+
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
|
|
281
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
282
|
+
}
|
|
283
|
+
return BYTE_HEX[0xF0 | (cp >> 18)] +
|
|
284
|
+
BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] +
|
|
285
|
+
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
|
|
286
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
287
|
+
}
|
|
202
288
|
|
|
203
289
|
/**
|
|
204
290
|
* Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
|
|
205
|
-
* Reserved delimiters such as `%2F`
|
|
291
|
+
* Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
|
|
206
292
|
*
|
|
207
293
|
* @param {string} input
|
|
208
294
|
* @param {boolean} [decodeUnreserved=false]
|
|
@@ -251,7 +337,8 @@ function normalizePathEncoding (input) {
|
|
|
251
337
|
let output = ''
|
|
252
338
|
|
|
253
339
|
for (let i = 0; i < input.length; i++) {
|
|
254
|
-
|
|
340
|
+
const ch = input[i]
|
|
341
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
255
342
|
const hex = input.slice(i + 1, i + 3)
|
|
256
343
|
if (isHexPair(hex)) {
|
|
257
344
|
const normalizedHex = hex.toUpperCase()
|
|
@@ -268,10 +355,225 @@ function normalizePathEncoding (input) {
|
|
|
268
355
|
}
|
|
269
356
|
}
|
|
270
357
|
|
|
271
|
-
if (isPathCharacter(
|
|
272
|
-
output +=
|
|
358
|
+
if (isPathCharacter(ch)) {
|
|
359
|
+
output += ch
|
|
273
360
|
} else {
|
|
274
|
-
|
|
361
|
+
const code = input.charCodeAt(i)
|
|
362
|
+
if (code < 0x80) {
|
|
363
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
364
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
365
|
+
output += percentEncodeNonAscii(code)
|
|
366
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
367
|
+
const low = input.charCodeAt(i + 1)
|
|
368
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
369
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
370
|
+
i++
|
|
371
|
+
} else {
|
|
372
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return output
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Serializes a path without rewriting reserved data. Raw RFC 3986 path
|
|
385
|
+
* characters remain literal, valid escapes are preserved and uppercased, and
|
|
386
|
+
* everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the
|
|
387
|
+
* first segment must be escaped so the result cannot be parsed as a scheme.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} input
|
|
390
|
+
* @param {boolean} [pathNoScheme=false]
|
|
391
|
+
* @returns {string}
|
|
392
|
+
*/
|
|
393
|
+
function serializePathEncoding (input, pathNoScheme = false) {
|
|
394
|
+
let output = ''
|
|
395
|
+
let firstSegment = pathNoScheme && input[0] !== '/'
|
|
396
|
+
|
|
397
|
+
for (let i = 0; i < input.length; i++) {
|
|
398
|
+
const ch = input[i]
|
|
399
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
400
|
+
const hex = input.slice(i + 1, i + 3)
|
|
401
|
+
if (isHexPair(hex)) {
|
|
402
|
+
output += '%' + hex.toUpperCase()
|
|
403
|
+
i += 2
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (ch === '/') {
|
|
409
|
+
firstSegment = false
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) {
|
|
413
|
+
output += ch
|
|
414
|
+
} else {
|
|
415
|
+
const code = input.charCodeAt(i)
|
|
416
|
+
if (code < 0x80) {
|
|
417
|
+
output += BYTE_HEX[code]
|
|
418
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
419
|
+
output += percentEncodeNonAscii(code)
|
|
420
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
421
|
+
const low = input.charCodeAt(i + 1)
|
|
422
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
423
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
424
|
+
i++
|
|
425
|
+
} else {
|
|
426
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return output
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Percent-encodes a URI component using its RFC 3986 literal character set.
|
|
439
|
+
* Existing valid escapes are preserved and normalized to uppercase hex.
|
|
440
|
+
*
|
|
441
|
+
* @param {string} input
|
|
442
|
+
* @param {(value: string) => boolean} isAllowed
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
function encodeComponent (input, isAllowed) {
|
|
446
|
+
let output = ''
|
|
447
|
+
|
|
448
|
+
for (let i = 0; i < input.length; i++) {
|
|
449
|
+
const ch = input[i]
|
|
450
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
451
|
+
const hex = input.slice(i + 1, i + 3)
|
|
452
|
+
if (isHexPair(hex)) {
|
|
453
|
+
output += '%' + hex.toUpperCase()
|
|
454
|
+
i += 2
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (isAllowed(ch)) {
|
|
460
|
+
output += ch
|
|
461
|
+
} else {
|
|
462
|
+
const code = input.charCodeAt(i)
|
|
463
|
+
if (code < 0x80) {
|
|
464
|
+
output += BYTE_HEX[code]
|
|
465
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
466
|
+
output += percentEncodeNonAscii(code)
|
|
467
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
468
|
+
const low = input.charCodeAt(i + 1)
|
|
469
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
470
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
471
|
+
i++
|
|
472
|
+
} else {
|
|
473
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return output
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters.
|
|
486
|
+
* In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data.
|
|
487
|
+
*
|
|
488
|
+
* @param {string} input
|
|
489
|
+
* @returns {string}
|
|
490
|
+
*/
|
|
491
|
+
function encodeUserinfo (input) {
|
|
492
|
+
return encodeComponent(input, isUserinfoCharacter)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be
|
|
497
|
+
* escaped because it would otherwise begin the fragment component.
|
|
498
|
+
*
|
|
499
|
+
* @param {string} input
|
|
500
|
+
* @returns {string}
|
|
501
|
+
*/
|
|
502
|
+
function encodeQuery (input) {
|
|
503
|
+
return encodeComponent(input, isQueryFragmentCharacter)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Encodes fragment data using the RFC 3986 §3.5 grammar.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} input
|
|
510
|
+
* @returns {string}
|
|
511
|
+
*/
|
|
512
|
+
function encodeFragment (input) {
|
|
513
|
+
return encodeComponent(input, isQueryFragmentCharacter)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isEscapeSafe (cp) {
|
|
517
|
+
return (
|
|
518
|
+
(cp >= 0x30 && cp <= 0x39) ||
|
|
519
|
+
(cp >= 0x41 && cp <= 0x5A) ||
|
|
520
|
+
(cp >= 0x61 && cp <= 0x7A) ||
|
|
521
|
+
cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E ||
|
|
522
|
+
cp === 0x2F || cp === 0x40 || cp === 0x5F
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Normalizes the percent-encoding of a query or fragment component.
|
|
528
|
+
*
|
|
529
|
+
* Like `normalizePathEncoding`, but uses the query/fragment character set
|
|
530
|
+
* (which additionally allows `?`) and decodes `.` since it has no dot-segment
|
|
531
|
+
* meaning outside of a path.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} input
|
|
534
|
+
* @returns {string}
|
|
535
|
+
*/
|
|
536
|
+
function normalizeQueryFragmentEncoding (input) {
|
|
537
|
+
let output = ''
|
|
538
|
+
|
|
539
|
+
for (let i = 0; i < input.length; i++) {
|
|
540
|
+
const ch = input[i]
|
|
541
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
542
|
+
const hex = input.slice(i + 1, i + 3)
|
|
543
|
+
if (isHexPair(hex)) {
|
|
544
|
+
const normalizedHex = hex.toUpperCase()
|
|
545
|
+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
|
|
546
|
+
|
|
547
|
+
if (isUnreserved(decoded)) {
|
|
548
|
+
output += decoded
|
|
549
|
+
} else {
|
|
550
|
+
output += '%' + normalizedHex
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
i += 2
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (isQueryFragmentCharacter(ch)) {
|
|
559
|
+
output += ch
|
|
560
|
+
} else {
|
|
561
|
+
const code = input.charCodeAt(i)
|
|
562
|
+
if (code < 0x80) {
|
|
563
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
564
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
565
|
+
output += percentEncodeNonAscii(code)
|
|
566
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
567
|
+
const low = input.charCodeAt(i + 1)
|
|
568
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
569
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
570
|
+
i++
|
|
571
|
+
} else {
|
|
572
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
576
|
+
}
|
|
275
577
|
}
|
|
276
578
|
}
|
|
277
579
|
|
|
@@ -307,19 +609,21 @@ function recomposeAuthority (components, options) {
|
|
|
307
609
|
const uriTokens = []
|
|
308
610
|
|
|
309
611
|
if (components.userinfo !== undefined) {
|
|
310
|
-
uriTokens.push(components.userinfo)
|
|
612
|
+
uriTokens.push(encodeUserinfo(components.userinfo))
|
|
311
613
|
uriTokens.push('@')
|
|
312
614
|
}
|
|
313
615
|
|
|
314
616
|
if (components.host !== undefined) {
|
|
315
|
-
let host =
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
617
|
+
let host = components.host
|
|
618
|
+
if (!isIPv4(host)) {
|
|
619
|
+
let ipV6res = normalizeIPv6(host)
|
|
620
|
+
if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
|
|
621
|
+
// Decode only unreserved bytes, once. In particular, keep %25 encoded
|
|
622
|
+
// so it cannot introduce a second escape during recomposition.
|
|
623
|
+
host = normalizePercentEncoding(host, true)
|
|
624
|
+
ipV6res = normalizeIPv6(host)
|
|
625
|
+
}
|
|
626
|
+
if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
|
|
323
627
|
host = `[${ipV6res.escapedHost}]`
|
|
324
628
|
} else {
|
|
325
629
|
host = reescapeHostDelimiters(host, false)
|
|
@@ -329,21 +633,32 @@ function recomposeAuthority (components, options) {
|
|
|
329
633
|
}
|
|
330
634
|
|
|
331
635
|
if (typeof components.port === 'number' || typeof components.port === 'string') {
|
|
636
|
+
const port = String(components.port)
|
|
637
|
+
if (!isPort(port)) {
|
|
638
|
+
throw new TypeError('URI port is malformed.')
|
|
639
|
+
}
|
|
332
640
|
uriTokens.push(':')
|
|
333
|
-
uriTokens.push(
|
|
641
|
+
uriTokens.push(port)
|
|
334
642
|
}
|
|
335
643
|
|
|
336
644
|
return uriTokens.length ? uriTokens.join('') : undefined
|
|
337
645
|
};
|
|
338
646
|
|
|
339
647
|
module.exports = {
|
|
648
|
+
nonSimpleDomain,
|
|
340
649
|
recomposeAuthority,
|
|
341
650
|
reescapeHostDelimiters,
|
|
342
651
|
normalizePercentEncoding,
|
|
343
652
|
normalizePathEncoding,
|
|
653
|
+
serializePathEncoding,
|
|
654
|
+
normalizeQueryFragmentEncoding,
|
|
655
|
+
encodeUserinfo,
|
|
656
|
+
encodeQuery,
|
|
657
|
+
encodeFragment,
|
|
344
658
|
escapePreservingEscapes,
|
|
345
659
|
removeDotSegments,
|
|
346
|
-
|
|
660
|
+
isIPv4,
|
|
347
661
|
normalizeIPv6,
|
|
348
|
-
stringArrayToHexStripped
|
|
662
|
+
stringArrayToHexStripped,
|
|
663
|
+
isIPvFuture
|
|
349
664
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fast-uri",
|
|
3
3
|
"description": "Dependency free RFC 3986 URI toolbox",
|
|
4
|
-
"version": "2.4.
|
|
4
|
+
"version": "2.4.6",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"types": "types/index.d.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"snazzy": "^9.0.0",
|
|
32
32
|
"standard": "^17.0.0",
|
|
33
33
|
"tap": "^16.0.0",
|
|
34
|
+
"tape": "^5.8.1",
|
|
34
35
|
"tsd": "^0.31.0",
|
|
35
36
|
"uri-js": "^4.4.1"
|
|
36
37
|
}
|
|
@@ -57,6 +57,18 @@ test('compatibility Parse', (t) => {
|
|
|
57
57
|
// 'mailto:user@%E7%B4%8D%E8%B1%86.example.org?subject=Test&body=NATTO'
|
|
58
58
|
]
|
|
59
59
|
toParse.forEach((x) => {
|
|
60
|
+
if (x === '//10.10.000.10') {
|
|
61
|
+
t.skip('Skipping //10.10.000.10 as it is not a valid URI per URI spec: https://datatracker.ietf.org/doc/html/rfc5954#section-4.1')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
// Malformed bracketed IP literals are rejected by fast-uri without being
|
|
65
|
+
// rewritten; uri-js silently accepts them, so pin the expected output.
|
|
66
|
+
if (x === '//[2001:dbZ::1]:80' || x === '//[2606:2800:220:1:248:1893:25c8:1946:43209]') {
|
|
67
|
+
const parsed = fastifyURI.parse(x)
|
|
68
|
+
t.equal(parsed.error, 'URI host is malformed.', 'Compatibility parse: ' + x)
|
|
69
|
+
t.equal(parsed.host, x.slice(2, x.indexOf(']') + 1).toLowerCase(), 'Compatibility parse host: ' + x)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
60
72
|
t.same(fastifyURI.parse(x), urijs.parse(x), 'Compatibility parse: ' + x)
|
|
61
73
|
})
|
|
62
74
|
t.end()
|