@openclaw/lobster 2026.6.33 → 2026.6.35

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.
Files changed (41) hide show
  1. package/node_modules/fast-uri/.github/dependabot.yml +42 -2
  2. package/node_modules/fast-uri/.github/workflows/ci.yml +54 -6
  3. package/node_modules/fast-uri/.github/workflows/lock-threads.yml +1 -1
  4. package/node_modules/fast-uri/.github/workflows/ossf-scorecard.yml +45 -0
  5. package/node_modules/fast-uri/README.md +10 -9
  6. package/node_modules/fast-uri/SECURITY.md +173 -0
  7. package/node_modules/fast-uri/benchmark/benchmark.mjs +71 -0
  8. package/node_modules/fast-uri/benchmark/encoder.mjs +54 -0
  9. package/node_modules/fast-uri/index.js +277 -45
  10. package/node_modules/fast-uri/lib/schemes.js +384 -7
  11. package/node_modules/fast-uri/lib/utils.js +458 -95
  12. package/node_modules/fast-uri/package.json +4 -4
  13. package/node_modules/fast-uri/test/component-safe-serialization.test.js +163 -0
  14. package/node_modules/fast-uri/test/equal.test.js +94 -3
  15. package/node_modules/fast-uri/test/fixtures/uri-js-parse.json +24 -1
  16. package/node_modules/fast-uri/test/fixtures/uri-js-serialize.json +23 -1
  17. package/node_modules/fast-uri/test/ipv6-canonical.test.js +34 -0
  18. package/node_modules/fast-uri/test/ipv6-validation.test.js +154 -0
  19. package/node_modules/fast-uri/test/malformed-percent.test.js +77 -0
  20. package/node_modules/fast-uri/test/malformed-urn.test.js +61 -0
  21. package/node_modules/fast-uri/test/parse.test.js +150 -3
  22. package/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
  23. package/node_modules/fast-uri/test/reserved-path-normalization.test.js +109 -0
  24. package/node_modules/fast-uri/test/scheme-normalization.test.js +16 -0
  25. package/node_modules/fast-uri/test/scheme-validation.test.js +122 -0
  26. package/node_modules/fast-uri/test/security-normalization.test.js +101 -0
  27. package/node_modules/fast-uri/test/security-utf8-encoding.test.js +0 -0
  28. package/node_modules/fast-uri/test/security.test.js +301 -3
  29. package/node_modules/fast-uri/test/serialize.test.js +144 -0
  30. package/node_modules/fast-uri/test/uri-js-compatibility.test.js +0 -3
  31. package/node_modules/fast-uri/test/uri-js.test.js +4 -4
  32. package/node_modules/fast-uri/test/urn-full-input.test.js +29 -0
  33. package/node_modules/fast-uri/test/util.test.js +9 -0
  34. package/node_modules/fast-uri/test/websocket-query-preservation.test.js +24 -0
  35. package/node_modules/fast-uri/tsconfig.json +8 -7
  36. package/node_modules/fast-uri/types/index.d.ts +4 -9
  37. package/node_modules/fast-uri/types/index.tst.ts +31 -0
  38. package/npm-shrinkwrap.json +5 -5
  39. package/package.json +4 -4
  40. package/node_modules/fast-uri/.github/workflows/package-manager-ci.yml +0 -24
  41. package/node_modules/fast-uri/types/index.test-d.ts +0 -17
@@ -6,6 +6,9 @@ const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\d
6
6
  /** @type {(value: string) => boolean} */
7
7
  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)
8
8
 
9
+ /** @type {(value: string) => boolean} */
10
+ const isPort = RegExp.prototype.test.bind(/^\d*$/u)
11
+
9
12
  /** @type {(value: string) => boolean} */
10
13
  const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
11
14
 
@@ -13,7 +16,13 @@ const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
13
16
  const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
14
17
 
15
18
  /** @type {(value: string) => boolean} */
16
- const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
19
+ const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u)
20
+
21
+ /** @type {(value: string) => boolean} */
22
+ const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u)
23
+
24
+ /** @type {(value: string) => boolean} */
25
+ const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
17
26
 
18
27
  /**
19
28
  * @param {Array<string>} input
@@ -46,12 +55,14 @@ function stringArrayToHexStripped (input) {
46
55
  return acc
47
56
  }
48
57
 
49
- /**
50
- * @typedef {Object} GetIPV6Result
51
- * @property {boolean} error - Indicates if there was an error parsing the IPv6 address.
52
- * @property {string} address - The parsed IPv6 address.
53
- * @property {string} [zone] - The zone identifier, if present.
54
- */
58
+ /** @type {(value: string) => boolean} */
59
+ const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
60
+
61
+ /** @type {(value: string) => boolean} */
62
+ const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
63
+
64
+ /** @type {(value: string) => boolean} */
65
+ const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
55
66
 
56
67
  /**
57
68
  * @param {string} value
@@ -59,89 +70,129 @@ function stringArrayToHexStripped (input) {
59
70
  */
60
71
  const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
61
72
 
73
+ /** @type {(value: string) => boolean} */
74
+ const nonSimpleDomainChars = RegExp.prototype.test.bind(/[^\d!"$&'()*+,\-.;=_`a-z{}~]/u)
75
+
76
+ /** @type {(value: string) => boolean} */
77
+ const numericLastLabel = RegExp.prototype.test.bind(/(?:^|\.)\d+$/u)
78
+
62
79
  /**
63
- * @param {Array<string>} buffer
80
+ * True when `new URL('http://' + domain).hostname` may differ from `domain`,
81
+ * i.e. when the caller must fall back to the WHATWG parser.
82
+ *
83
+ * Same purpose as `nonSimpleDomain`, but digits are allowed: for this character
84
+ * set `hostname` is the identity, *except* that an all-numeric final label is
85
+ * read as IPv4 shorthand ("1.2.3" -> "1.2.0.3", "127.1" -> "127.0.0.1",
86
+ * "a.b.1" throws), which is why `nonSimpleDomain` excludes digits outright.
87
+ * Rejecting that one shape lets the common `mail2.example.org` case stay on the
88
+ * fast path.
89
+ *
90
+ * @param {string} domain - must already be lowercased
64
91
  * @returns {boolean}
65
92
  */
66
- function consumeIsZone (buffer) {
67
- buffer.length = 0
68
- return true
93
+ function nonSimpleMailtoDomain (domain) {
94
+ return nonSimpleDomainChars(domain) || numericLastLabel(domain)
69
95
  }
70
96
 
71
97
  /**
72
- * @param {Array<string>} buffer
73
- * @param {Array<string>} address
74
- * @param {GetIPV6Result} output
98
+ * @param {string} zone
75
99
  * @returns {boolean}
76
100
  */
77
- function consumeHextets (buffer, address, output) {
78
- if (buffer.length) {
79
- const hex = stringArrayToHexStripped(buffer)
80
- if (hex !== '') {
81
- address.push(hex)
82
- } else {
83
- output.error = true
84
- return false
101
+ function isZoneIdentifier (zone) {
102
+ if (zone.length === 0) return false
103
+
104
+ for (let i = 0; i < zone.length; i++) {
105
+ if (isZoneCharacter(zone[i])) continue
106
+ if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
107
+ i += 2
108
+ continue
85
109
  }
86
- buffer.length = 0
110
+ return false
87
111
  }
112
+
88
113
  return true
89
114
  }
90
115
 
91
116
  /**
92
- * @param {string} input
93
- * @returns {GetIPV6Result}
117
+ * Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
118
+ * single zero hextet is left uncompressed. On ties the leftmost run wins.
119
+ *
120
+ * @param {string[]} hextets
121
+ * @returns {string}
94
122
  */
95
- function getIPV6 (input) {
96
- let tokenCount = 0
97
- const output = { error: false, address: '', zone: '' }
98
- /** @type {Array<string>} */
99
- const address = []
100
- /** @type {Array<string>} */
101
- const buffer = []
102
- let endipv6Encountered = false
103
- let endIpv6 = false
104
-
105
- let consume = consumeHextets
106
-
107
- for (let i = 0; i < input.length; i++) {
108
- const cursor = input[i]
109
- if (cursor === '[' || cursor === ']') { continue }
110
- if (cursor === ':') {
111
- if (endipv6Encountered === true) {
112
- endIpv6 = true
123
+ function compressIPv6ZeroRun (hextets) {
124
+ let bestStart = -1
125
+ let bestLength = 0
126
+ let runStart = -1
127
+ let runLength = 0
128
+ for (let i = 0; i < hextets.length; i++) {
129
+ if (hextets[i] === '0') {
130
+ if (runStart === -1) runStart = i
131
+ runLength++
132
+ if (runLength > bestLength) {
133
+ bestLength = runLength
134
+ bestStart = runStart
113
135
  }
114
- if (!consume(buffer, address, output)) { break }
115
- if (++tokenCount > 7) {
116
- // not valid
117
- output.error = true
118
- break
119
- }
120
- if (i > 0 && input[i - 1] === ':') {
121
- endipv6Encountered = true
122
- }
123
- address.push(':')
124
- continue
125
- } else if (cursor === '%') {
126
- if (!consume(buffer, address, output)) { break }
127
- // switch to zone detection
128
- consume = consumeIsZone
129
136
  } else {
130
- buffer.push(cursor)
131
- continue
137
+ runStart = -1
138
+ runLength = 0
132
139
  }
133
140
  }
134
- if (buffer.length) {
135
- if (consume === consumeIsZone) {
136
- output.zone = buffer.join('')
137
- } else if (endIpv6) {
138
- address.push(buffer.join(''))
139
- } else {
140
- address.push(stringArrayToHexStripped(buffer))
141
+
142
+ if (bestLength < 2) return hextets.join(':')
143
+
144
+ const head = hextets.slice(0, bestStart).join(':')
145
+ const tail = hextets.slice(bestStart + bestLength).join(':')
146
+ return head + '::' + tail
147
+ }
148
+
149
+ /**
150
+ * Validates an IPv6 address against the alternatives in RFC 3986 section
151
+ * 3.2.2 and returns the same address with leading hextet zeroes removed.
152
+ * An embedded IPv4 address counts as two hextets and is only valid at the end.
153
+ *
154
+ * @param {string} input
155
+ * @returns {string|undefined}
156
+ */
157
+ function normalizeIPv6Address (input) {
158
+ const compression = input.indexOf('::')
159
+ if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
160
+
161
+ const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
162
+ const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
163
+ if (compression !== -1) {
164
+ if (left.length === 1 && left[0] === '') left.length = 0
165
+ if (right.length === 1 && right[0] === '') right.length = 0
166
+ }
167
+
168
+ const parts = left.concat(right)
169
+ let hextetCount = 0
170
+ for (let i = 0; i < parts.length; i++) {
171
+ const part = parts[i]
172
+ if (part === '') return undefined
173
+
174
+ if (part.indexOf('.') !== -1) {
175
+ if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
176
+ hextetCount += 2
177
+ continue
141
178
  }
179
+
180
+ if (!isHextet(part)) return undefined
181
+ parts[i] = parseInt(part, 16).toString(16)
182
+ hextetCount++
142
183
  }
143
- output.address = address.join('')
144
- return output
184
+
185
+ if (compression === -1) {
186
+ if (hextetCount !== 8) return undefined
187
+ return compressIPv6ZeroRun(parts)
188
+ }
189
+ if (hextetCount >= 8) return undefined
190
+
191
+ // expand "::" then re-compress the longest run for a canonical result
192
+ const expanded = parts.slice(0, left.length)
193
+ for (let i = hextetCount; i < 8; i++) expanded.push('0')
194
+ for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
195
+ return compressIPv6ZeroRun(expanded)
145
196
  }
146
197
 
147
198
  /**
@@ -149,26 +200,49 @@ function getIPV6 (input) {
149
200
  * @property {string} host - The normalized host.
150
201
  * @property {string} [escapedHost] - The escaped host.
151
202
  * @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
203
+ * @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
204
+ * @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
152
205
  */
153
206
 
154
207
  /**
208
+ * Validates and normalizes a bracketed IP literal. Raw zone separators remain
209
+ * accepted for backwards compatibility, while encoded separators and zone
210
+ * contents follow RFC 6874.
211
+ *
155
212
  * @param {string} host
156
213
  * @returns {NormalizeIPv6Result}
157
214
  */
158
215
  function normalizeIPv6 (host) {
159
- if (findToken(host, ':') < 2) { return { host, isIPV6: false } }
160
- const ipv6 = getIPV6(host)
161
-
162
- if (!ipv6.error) {
163
- let newHost = ipv6.address
164
- let escapedHost = ipv6.address
165
- if (ipv6.zone) {
166
- newHost += '%' + ipv6.zone
167
- escapedHost += '%25' + ipv6.zone
168
- }
169
- return { host: newHost, isIPV6: true, escapedHost }
170
- } else {
171
- return { host, isIPV6: false }
216
+ const bracketed = host[0] === '[' && host[host.length - 1] === ']'
217
+ const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
218
+ if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
219
+
220
+ let input = bracketed ? host.slice(1, -1) : host
221
+ if (bracketed && isIPvFuture(input)) {
222
+ input = input.toLowerCase()
223
+ return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
224
+ }
225
+
226
+ if (findToken(input, ':') < 2) {
227
+ return { host, isIPV6: false, error: bracketed }
228
+ }
229
+
230
+ let zoneIdentifier = ''
231
+ const zoneSeparator = input.indexOf('%')
232
+ if (zoneSeparator !== -1) {
233
+ const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
234
+ zoneIdentifier = input.slice(zoneSeparator + separatorLength)
235
+ if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
236
+ input = input.slice(0, zoneSeparator)
237
+ }
238
+
239
+ const address = normalizeIPv6Address(input)
240
+ if (address === undefined) return { host, isIPV6: false, error: true }
241
+
242
+ return {
243
+ host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
244
+ escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
245
+ isIPV6: true
172
246
  }
173
247
  }
174
248
 
@@ -218,7 +292,7 @@ function removeDotSegments (path) {
218
292
  continue
219
293
  }
220
294
  } else if (input[0] === '/') {
221
- if (input[1] === '.' || input[1] === '/') {
295
+ if (input[1] === '.') {
222
296
  output.push('/')
223
297
  break
224
298
  }
@@ -294,7 +368,7 @@ function reescapeHostDelimiters (host, isIP) {
294
368
 
295
369
  /**
296
370
  * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
297
- * Reserved delimiters such as `%2F` and `%2E` stay escaped.
371
+ * Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
298
372
  *
299
373
  * @param {string} input
300
374
  * @param {boolean} [decodeUnreserved=false]
@@ -331,6 +405,55 @@ function normalizePercentEncoding (input, decodeUnreserved = false) {
331
405
  return output
332
406
  }
333
407
 
408
+ /** Pre-built `%XX` strings for every byte. Avoids per-call hex math/concat. */
409
+ const BYTE_HEX = new Array(256)
410
+ {
411
+ const HEX_DIGITS = '0123456789ABCDEF'
412
+ for (let i = 0; i < 256; i++) {
413
+ BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF]
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Mirrors the pass-through set of the deprecated `escape()` global:
419
+ * `A-Z a-z 0-9 * + - . / @ _`. Keeps drop-in semantics for ASCII input.
420
+ *
421
+ * @param {number} cp
422
+ * @returns {boolean}
423
+ */
424
+ function isEscapeSafe (cp) {
425
+ return (
426
+ (cp >= 0x30 && cp <= 0x39) ||
427
+ (cp >= 0x41 && cp <= 0x5A) ||
428
+ (cp >= 0x61 && cp <= 0x7A) ||
429
+ cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E ||
430
+ cp === 0x2F || cp === 0x40 || cp === 0x5F
431
+ )
432
+ }
433
+
434
+ /**
435
+ * RFC 3986 percent-encode a non-ASCII Unicode code point as UTF-8 bytes.
436
+ * Caller handles the ASCII branch inline for speed.
437
+ *
438
+ * @param {number} cp - Unicode code point (>= 0x80, <= 0x10FFFF)
439
+ * @returns {string}
440
+ */
441
+ function percentEncodeNonAscii (cp) {
442
+ if (cp < 0x800) {
443
+ return BYTE_HEX[0xC0 | (cp >> 6)] +
444
+ BYTE_HEX[0x80 | (cp & 0x3F)]
445
+ }
446
+ if (cp < 0x10000) {
447
+ return BYTE_HEX[0xE0 | (cp >> 12)] +
448
+ BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
449
+ BYTE_HEX[0x80 | (cp & 0x3F)]
450
+ }
451
+ return BYTE_HEX[0xF0 | (cp >> 18)] +
452
+ BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] +
453
+ BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
454
+ BYTE_HEX[0x80 | (cp & 0x3F)]
455
+ }
456
+
334
457
  /**
335
458
  * Normalizes path data without turning reserved escapes into live path syntax.
336
459
  * Valid escapes are uppercased, raw unsafe characters are escaped, and only
@@ -343,7 +466,8 @@ function normalizePathEncoding (input) {
343
466
  let output = ''
344
467
 
345
468
  for (let i = 0; i < input.length; i++) {
346
- if (input[i] === '%' && i + 2 < input.length) {
469
+ const ch = input[i]
470
+ if (ch === '%' && i + 2 < input.length) {
347
471
  const hex = input.slice(i + 1, i + 3)
348
472
  if (isHexPair(hex)) {
349
473
  const normalizedHex = hex.toUpperCase()
@@ -360,16 +484,221 @@ function normalizePathEncoding (input) {
360
484
  }
361
485
  }
362
486
 
363
- if (isPathCharacter(input[i])) {
364
- output += input[i]
487
+ if (isPathCharacter(ch)) {
488
+ output += ch
489
+ } else {
490
+ const code = input.charCodeAt(i)
491
+ if (code < 0x80) {
492
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
493
+ } else if (code < 0xD800 || code > 0xDFFF) {
494
+ output += percentEncodeNonAscii(code)
495
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
496
+ const low = input.charCodeAt(i + 1)
497
+ if (low >= 0xDC00 && low <= 0xDFFF) {
498
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
499
+ i++
500
+ } else {
501
+ output += percentEncodeNonAscii(0xFFFD)
502
+ }
503
+ } else {
504
+ output += percentEncodeNonAscii(0xFFFD)
505
+ }
506
+ }
507
+ }
508
+
509
+ return output
510
+ }
511
+
512
+ /**
513
+ * Serializes a path without rewriting reserved data. Raw RFC 3986 path
514
+ * characters remain literal, valid escapes are preserved and uppercased, and
515
+ * everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the
516
+ * first segment must be escaped so the result cannot be parsed as a scheme.
517
+ *
518
+ * @param {string} input
519
+ * @param {boolean} [pathNoScheme=false]
520
+ * @returns {string}
521
+ */
522
+ function serializePathEncoding (input, pathNoScheme = false) {
523
+ let output = ''
524
+ let firstSegment = pathNoScheme && input[0] !== '/'
525
+
526
+ for (let i = 0; i < input.length; i++) {
527
+ const ch = input[i]
528
+ if (ch === '%' && i + 2 < input.length) {
529
+ const hex = input.slice(i + 1, i + 3)
530
+ if (isHexPair(hex)) {
531
+ output += '%' + hex.toUpperCase()
532
+ i += 2
533
+ continue
534
+ }
535
+ }
536
+
537
+ if (ch === '/') {
538
+ firstSegment = false
539
+ }
540
+
541
+ if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) {
542
+ output += ch
543
+ } else {
544
+ const code = input.charCodeAt(i)
545
+ if (code < 0x80) {
546
+ output += BYTE_HEX[code]
547
+ } else if (code < 0xD800 || code > 0xDFFF) {
548
+ output += percentEncodeNonAscii(code)
549
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
550
+ const low = input.charCodeAt(i + 1)
551
+ if (low >= 0xDC00 && low <= 0xDFFF) {
552
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
553
+ i++
554
+ } else {
555
+ output += percentEncodeNonAscii(0xFFFD)
556
+ }
557
+ } else {
558
+ output += percentEncodeNonAscii(0xFFFD)
559
+ }
560
+ }
561
+ }
562
+
563
+ return output
564
+ }
565
+
566
+ /**
567
+ * Normalizes the percent-encoding of a query or fragment component.
568
+ *
569
+ * Like `normalizePathEncoding`, but uses the query/fragment character set
570
+ * (which additionally allows `?`) and decodes `.` since it has no dot-segment
571
+ * meaning outside of a path.
572
+ *
573
+ * @param {string} input
574
+ * @returns {string}
575
+ */
576
+ function normalizeQueryFragmentEncoding (input) {
577
+ let output = ''
578
+
579
+ for (let i = 0; i < input.length; i++) {
580
+ const ch = input[i]
581
+ if (ch === '%' && i + 2 < input.length) {
582
+ const hex = input.slice(i + 1, i + 3)
583
+ if (isHexPair(hex)) {
584
+ const normalizedHex = hex.toUpperCase()
585
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
586
+
587
+ if (isUnreserved(decoded)) {
588
+ output += decoded
589
+ } else {
590
+ output += '%' + normalizedHex
591
+ }
592
+
593
+ i += 2
594
+ continue
595
+ }
596
+ }
597
+
598
+ if (isQueryFragmentCharacter(ch)) {
599
+ output += ch
600
+ } else {
601
+ const code = input.charCodeAt(i)
602
+ if (code < 0x80) {
603
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
604
+ } else if (code < 0xD800 || code > 0xDFFF) {
605
+ output += percentEncodeNonAscii(code)
606
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
607
+ const low = input.charCodeAt(i + 1)
608
+ if (low >= 0xDC00 && low <= 0xDFFF) {
609
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
610
+ i++
611
+ } else {
612
+ output += percentEncodeNonAscii(0xFFFD)
613
+ }
614
+ } else {
615
+ output += percentEncodeNonAscii(0xFFFD)
616
+ }
617
+ }
618
+ }
619
+
620
+ return output
621
+ }
622
+
623
+ /**
624
+ * Percent-encodes a URI component using its RFC 3986 literal character set.
625
+ * Existing valid escapes are preserved and normalized to uppercase hex.
626
+ *
627
+ * @param {string} input
628
+ * @param {(value: string) => boolean} isAllowed
629
+ * @returns {string}
630
+ */
631
+ function encodeComponent (input, isAllowed) {
632
+ let output = ''
633
+
634
+ for (let i = 0; i < input.length; i++) {
635
+ const ch = input[i]
636
+ if (ch === '%' && i + 2 < input.length) {
637
+ const hex = input.slice(i + 1, i + 3)
638
+ if (isHexPair(hex)) {
639
+ output += '%' + hex.toUpperCase()
640
+ i += 2
641
+ continue
642
+ }
643
+ }
644
+
645
+ if (isAllowed(ch)) {
646
+ output += ch
365
647
  } else {
366
- output += escape(input[i])
648
+ const code = input.charCodeAt(i)
649
+ if (code < 0x80) {
650
+ output += BYTE_HEX[code]
651
+ } else if (code < 0xD800 || code > 0xDFFF) {
652
+ output += percentEncodeNonAscii(code)
653
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
654
+ const low = input.charCodeAt(i + 1)
655
+ if (low >= 0xDC00 && low <= 0xDFFF) {
656
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
657
+ i++
658
+ } else {
659
+ output += percentEncodeNonAscii(0xFFFD)
660
+ }
661
+ } else {
662
+ output += percentEncodeNonAscii(0xFFFD)
663
+ }
367
664
  }
368
665
  }
369
666
 
370
667
  return output
371
668
  }
372
669
 
670
+ /**
671
+ * Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters.
672
+ * In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data.
673
+ *
674
+ * @param {string} input
675
+ * @returns {string}
676
+ */
677
+ function encodeUserinfo (input) {
678
+ return encodeComponent(input, isUserinfoCharacter)
679
+ }
680
+
681
+ /**
682
+ * Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be
683
+ * escaped because it would otherwise begin the fragment component.
684
+ *
685
+ * @param {string} input
686
+ * @returns {string}
687
+ */
688
+ function encodeQuery (input) {
689
+ return encodeComponent(input, isQueryFragmentCharacter)
690
+ }
691
+
692
+ /**
693
+ * Encodes fragment data using the RFC 3986 §3.5 grammar.
694
+ *
695
+ * @param {string} input
696
+ * @returns {string}
697
+ */
698
+ function encodeFragment (input) {
699
+ return encodeComponent(input, isQueryFragmentCharacter)
700
+ }
701
+
373
702
  /**
374
703
  * Escapes a component while preserving existing valid percent escapes.
375
704
  *
@@ -380,7 +709,8 @@ function escapePreservingEscapes (input) {
380
709
  let output = ''
381
710
 
382
711
  for (let i = 0; i < input.length; i++) {
383
- if (input[i] === '%' && i + 2 < input.length) {
712
+ const ch = input[i]
713
+ if (ch === '%' && i + 2 < input.length) {
384
714
  const hex = input.slice(i + 1, i + 3)
385
715
  if (isHexPair(hex)) {
386
716
  output += '%' + hex.toUpperCase()
@@ -389,7 +719,22 @@ function escapePreservingEscapes (input) {
389
719
  }
390
720
  }
391
721
 
392
- output += escape(input[i])
722
+ const code = input.charCodeAt(i)
723
+ if (code < 0x80) {
724
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
725
+ } else if (code < 0xD800 || code > 0xDFFF) {
726
+ output += percentEncodeNonAscii(code)
727
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
728
+ const low = input.charCodeAt(i + 1)
729
+ if (low >= 0xDC00 && low <= 0xDFFF) {
730
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
731
+ i++
732
+ } else {
733
+ output += percentEncodeNonAscii(0xFFFD)
734
+ }
735
+ } else {
736
+ output += percentEncodeNonAscii(0xFFFD)
737
+ }
393
738
  }
394
739
 
395
740
  return output
@@ -403,15 +748,21 @@ function recomposeAuthority (component) {
403
748
  const uriTokens = []
404
749
 
405
750
  if (component.userinfo !== undefined) {
406
- uriTokens.push(component.userinfo)
751
+ uriTokens.push(encodeUserinfo(component.userinfo))
407
752
  uriTokens.push('@')
408
753
  }
409
754
 
410
755
  if (component.host !== undefined) {
411
- let host = unescape(component.host)
756
+ let host = component.host
412
757
  if (!isIPv4(host)) {
413
- const ipV6res = normalizeIPv6(host)
414
- if (ipV6res.isIPV6 === true) {
758
+ let ipV6res = normalizeIPv6(host)
759
+ if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
760
+ // Decode only unreserved bytes, once. In particular, keep %25 encoded
761
+ // so it cannot introduce a second escape during recomposition.
762
+ host = normalizePercentEncoding(host, true)
763
+ ipV6res = normalizeIPv6(host)
764
+ }
765
+ if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
415
766
  host = `[${ipV6res.escapedHost}]`
416
767
  } else {
417
768
  host = reescapeHostDelimiters(host, false)
@@ -421,19 +772,31 @@ function recomposeAuthority (component) {
421
772
  }
422
773
 
423
774
  if (typeof component.port === 'number' || typeof component.port === 'string') {
775
+ const port = String(component.port)
776
+ if (!isPort(port)) {
777
+ throw new TypeError('URI port is malformed.')
778
+ }
424
779
  uriTokens.push(':')
425
- uriTokens.push(String(component.port))
780
+ uriTokens.push(port)
426
781
  }
427
782
 
428
783
  return uriTokens.length ? uriTokens.join('') : undefined
429
784
  };
430
785
 
431
786
  module.exports = {
787
+ BYTE_HEX,
788
+ percentEncodeNonAscii,
432
789
  nonSimpleDomain,
790
+ nonSimpleMailtoDomain,
433
791
  recomposeAuthority,
434
792
  reescapeHostDelimiters,
435
793
  normalizePercentEncoding,
436
794
  normalizePathEncoding,
795
+ serializePathEncoding,
796
+ normalizeQueryFragmentEncoding,
797
+ encodeUserinfo,
798
+ encodeQuery,
799
+ encodeFragment,
437
800
  escapePreservingEscapes,
438
801
  removeDotSegments,
439
802
  isIPv4,