fast-uri 4.1.2 → 4.1.4

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/lib/utils.js CHANGED
@@ -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,10 +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)
17
23
 
18
24
  /** @type {(value: string) => boolean} */
19
- const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/?]$/iu)
25
+ const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
20
26
 
21
27
  /**
22
28
  * @param {Array<string>} input
@@ -49,12 +55,14 @@ function stringArrayToHexStripped (input) {
49
55
  return acc
50
56
  }
51
57
 
52
- /**
53
- * @typedef {Object} GetIPV6Result
54
- * @property {boolean} error - Indicates if there was an error parsing the IPv6 address.
55
- * @property {string} address - The parsed IPv6 address.
56
- * @property {string} [zone] - The zone identifier, if present.
57
- */
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\-._~]$/)
58
66
 
59
67
  /**
60
68
  * @param {string} value
@@ -62,89 +70,129 @@ function stringArrayToHexStripped (input) {
62
70
  */
63
71
  const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
64
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
+
65
79
  /**
66
- * @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
67
91
  * @returns {boolean}
68
92
  */
69
- function consumeIsZone (buffer) {
70
- buffer.length = 0
71
- return true
93
+ function nonSimpleMailtoDomain (domain) {
94
+ return nonSimpleDomainChars(domain) || numericLastLabel(domain)
72
95
  }
73
96
 
74
97
  /**
75
- * @param {Array<string>} buffer
76
- * @param {Array<string>} address
77
- * @param {GetIPV6Result} output
98
+ * @param {string} zone
78
99
  * @returns {boolean}
79
100
  */
80
- function consumeHextets (buffer, address, output) {
81
- if (buffer.length) {
82
- const hex = stringArrayToHexStripped(buffer)
83
- if (hex !== '') {
84
- address.push(hex)
85
- } else {
86
- output.error = true
87
- 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
88
109
  }
89
- buffer.length = 0
110
+ return false
90
111
  }
112
+
91
113
  return true
92
114
  }
93
115
 
94
116
  /**
95
- * @param {string} input
96
- * @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}
97
122
  */
98
- function getIPV6 (input) {
99
- let tokenCount = 0
100
- const output = { error: false, address: '', zone: '' }
101
- /** @type {Array<string>} */
102
- const address = []
103
- /** @type {Array<string>} */
104
- const buffer = []
105
- let endipv6Encountered = false
106
- let endIpv6 = false
107
-
108
- let consume = consumeHextets
109
-
110
- for (let i = 0; i < input.length; i++) {
111
- const cursor = input[i]
112
- if (cursor === '[' || cursor === ']') { continue }
113
- if (cursor === ':') {
114
- if (endipv6Encountered === true) {
115
- 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
116
135
  }
117
- if (!consume(buffer, address, output)) { break }
118
- if (++tokenCount > 7) {
119
- // not valid
120
- output.error = true
121
- break
122
- }
123
- if (i > 0 && input[i - 1] === ':') {
124
- endipv6Encountered = true
125
- }
126
- address.push(':')
127
- continue
128
- } else if (cursor === '%') {
129
- if (!consume(buffer, address, output)) { break }
130
- // switch to zone detection
131
- consume = consumeIsZone
132
136
  } else {
133
- buffer.push(cursor)
134
- continue
137
+ runStart = -1
138
+ runLength = 0
135
139
  }
136
140
  }
137
- if (buffer.length) {
138
- if (consume === consumeIsZone) {
139
- output.zone = buffer.join('')
140
- } else if (endIpv6) {
141
- address.push(buffer.join(''))
142
- } else {
143
- 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
144
178
  }
179
+
180
+ if (!isHextet(part)) return undefined
181
+ parts[i] = parseInt(part, 16).toString(16)
182
+ hextetCount++
145
183
  }
146
- output.address = address.join('')
147
- 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)
148
196
  }
149
197
 
150
198
  /**
@@ -152,26 +200,49 @@ function getIPV6 (input) {
152
200
  * @property {string} host - The normalized host.
153
201
  * @property {string} [escapedHost] - The escaped host.
154
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.
155
205
  */
156
206
 
157
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
+ *
158
212
  * @param {string} host
159
213
  * @returns {NormalizeIPv6Result}
160
214
  */
161
215
  function normalizeIPv6 (host) {
162
- if (findToken(host, ':') < 2) { return { host, isIPV6: false } }
163
- const ipv6 = getIPV6(host)
164
-
165
- if (!ipv6.error) {
166
- let newHost = ipv6.address
167
- let escapedHost = ipv6.address
168
- if (ipv6.zone) {
169
- newHost += '%' + ipv6.zone
170
- escapedHost += '%25' + ipv6.zone
171
- }
172
- return { host: newHost, isIPV6: true, escapedHost }
173
- } else {
174
- 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
175
246
  }
176
247
  }
177
248
 
@@ -297,7 +368,7 @@ function reescapeHostDelimiters (host, isIP) {
297
368
 
298
369
  /**
299
370
  * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
300
- * Reserved delimiters such as `%2F` and `%2E` stay escaped.
371
+ * Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
301
372
  *
302
373
  * @param {string} input
303
374
  * @param {boolean} [decodeUnreserved=false]
@@ -438,6 +509,60 @@ function normalizePathEncoding (input) {
438
509
  return output
439
510
  }
440
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
+
441
566
  /**
442
567
  * Normalizes the percent-encoding of a query or fragment component.
443
568
  *
@@ -495,6 +620,85 @@ function normalizeQueryFragmentEncoding (input) {
495
620
  return output
496
621
  }
497
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
647
+ } else {
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
+ }
664
+ }
665
+ }
666
+
667
+ return output
668
+ }
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
+
498
702
  /**
499
703
  * Escapes a component while preserving existing valid percent escapes.
500
704
  *
@@ -544,15 +748,21 @@ function recomposeAuthority (component) {
544
748
  const uriTokens = []
545
749
 
546
750
  if (component.userinfo !== undefined) {
547
- uriTokens.push(component.userinfo)
751
+ uriTokens.push(encodeUserinfo(component.userinfo))
548
752
  uriTokens.push('@')
549
753
  }
550
754
 
551
755
  if (component.host !== undefined) {
552
- let host = unescape(component.host)
756
+ let host = component.host
553
757
  if (!isIPv4(host)) {
554
- const ipV6res = normalizeIPv6(host)
555
- 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) {
556
766
  host = `[${ipV6res.escapedHost}]`
557
767
  } else {
558
768
  host = reescapeHostDelimiters(host, false)
@@ -562,20 +772,31 @@ function recomposeAuthority (component) {
562
772
  }
563
773
 
564
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
+ }
565
779
  uriTokens.push(':')
566
- uriTokens.push(String(component.port))
780
+ uriTokens.push(port)
567
781
  }
568
782
 
569
783
  return uriTokens.length ? uriTokens.join('') : undefined
570
784
  };
571
785
 
572
786
  module.exports = {
787
+ BYTE_HEX,
788
+ percentEncodeNonAscii,
573
789
  nonSimpleDomain,
790
+ nonSimpleMailtoDomain,
574
791
  recomposeAuthority,
575
792
  reescapeHostDelimiters,
576
793
  normalizePercentEncoding,
577
794
  normalizePathEncoding,
795
+ serializePathEncoding,
578
796
  normalizeQueryFragmentEncoding,
797
+ encodeUserinfo,
798
+ encodeQuery,
799
+ encodeFragment,
579
800
  escapePreservingEscapes,
580
801
  removeDotSegments,
581
802
  isIPv4,
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": "4.1.2",
4
+ "version": "4.1.4",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -0,0 +1,163 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ test('userinfo serialization cannot terminate the authority', (t) => {
7
+ const uri = fastURI.serialize({
8
+ scheme: 'http',
9
+ userinfo: 'attacker.example/',
10
+ host: 'trusted.example',
11
+ path: '/'
12
+ })
13
+
14
+ t.equal(uri, 'http://attacker.example%2F@trusted.example/', 'slash is encoded as userinfo data')
15
+
16
+ const reparsed = fastURI.parse(uri)
17
+ t.equal(reparsed.host, 'trusted.example', 'reparsing retains the supplied host')
18
+ t.equal(reparsed.userinfo, 'attacker.example%2F', 'encoded slash remains in userinfo')
19
+ t.equal(
20
+ fastURI.serialize({ userinfo: 'user@/?:#[]\\', host: 'example.test' }),
21
+ '//user%40%2F%3F:%23%5B%5D%5C@example.test',
22
+ 'all userinfo component delimiters are encoded as data'
23
+ )
24
+ t.end()
25
+ })
26
+
27
+ test('port serialization rejects non-digit values', (t) => {
28
+ const malformedPorts = [
29
+ '@127.0.0.1:8124',
30
+ '8080@evil.example',
31
+ '8080/path',
32
+ '8080?query',
33
+ '8080#fragment',
34
+ '8080:9000',
35
+ '-1',
36
+ '1.5',
37
+ 1.5,
38
+ NaN,
39
+ Infinity,
40
+ '\u0661'
41
+ ]
42
+
43
+ for (const port of malformedPorts) {
44
+ t.throws(
45
+ () => fastURI.serialize({ scheme: 'http', host: 'trusted.example', port, path: '/app' }),
46
+ /URI port is malformed\./,
47
+ String(port)
48
+ )
49
+ }
50
+
51
+ t.throws(
52
+ () => fastURI.normalize({ scheme: 'http', host: 'trusted.example', port: '@evil.example' }),
53
+ /URI port is malformed\./,
54
+ 'object normalization rejects a malformed port'
55
+ )
56
+ t.equal(
57
+ fastURI.equal(
58
+ { scheme: 'http', host: 'trusted.example', port: '@evil.example' },
59
+ { scheme: 'http', host: 'trusted.example', port: '@evil.example' }
60
+ ),
61
+ false,
62
+ 'object equality fails closed for a malformed port'
63
+ )
64
+ t.end()
65
+ })
66
+
67
+ test('port serialization preserves RFC 3986 digit values', (t) => {
68
+ const validPorts = [
69
+ [8080, '8080'],
70
+ ['8080', '8080'],
71
+ ['00080', '00080'],
72
+ ['', '']
73
+ ]
74
+
75
+ for (const [port, expected] of validPorts) {
76
+ t.equal(
77
+ fastURI.serialize({ scheme: 'uri', host: 'example.test', port }),
78
+ `uri://example.test:${expected}`,
79
+ JSON.stringify(port)
80
+ )
81
+ }
82
+ t.end()
83
+ })
84
+
85
+ test('query and fragment serialization cannot inject component delimiters', (t) => {
86
+ const uri = fastURI.serialize({
87
+ scheme: 'x',
88
+ path: '/resource',
89
+ query: 'key=value#still-query',
90
+ fragment: 'first#still-fragment'
91
+ })
92
+
93
+ t.equal(
94
+ uri,
95
+ 'x:/resource?key=value%23still-query#first%23still-fragment',
96
+ 'raw hashes are encoded within their supplied components'
97
+ )
98
+
99
+ const reparsed = fastURI.parse(uri)
100
+ t.equal(reparsed.query, 'key=value%23still-query', 'hash remains query data')
101
+ t.equal(reparsed.fragment, 'first%23still-fragment', 'hash remains fragment data')
102
+ t.end()
103
+ })
104
+
105
+ test('component serializers preserve each RFC 3986 literal character set', (t) => {
106
+ const unreservedAndSubDelims = "AZaz09-._~!$&'()*+,;="
107
+ const userinfo = unreservedAndSubDelims + ':'
108
+ const queryOrFragment = unreservedAndSubDelims + ':@/?'
109
+
110
+ t.equal(
111
+ fastURI.serialize({ userinfo, host: 'example.test' }),
112
+ '//' + userinfo + '@example.test',
113
+ 'userinfo literals are preserved'
114
+ )
115
+ t.equal(
116
+ fastURI.serialize({ query: queryOrFragment }),
117
+ '?' + queryOrFragment,
118
+ 'query literals are preserved'
119
+ )
120
+ t.equal(
121
+ fastURI.serialize({ fragment: queryOrFragment }),
122
+ '#' + queryOrFragment,
123
+ 'fragment literals are preserved'
124
+ )
125
+ t.end()
126
+ })
127
+
128
+ test('component serializers preserve escapes and encode Unicode as UTF-8', (t) => {
129
+ t.equal(
130
+ fastURI.serialize({ userinfo: 'café/%2f%GG', host: 'example.test' }),
131
+ '//caf%C3%A9%2F%2F%25GG@example.test',
132
+ 'userinfo preserves valid escapes, uppercases hex, and escapes malformed percent data'
133
+ )
134
+ t.equal(
135
+ fastURI.serialize({ query: '日本/%2f#%GG' }),
136
+ '?%E6%97%A5%E6%9C%AC/%2F%23%25GG',
137
+ 'query uses UTF-8 and does not double encode valid escapes'
138
+ )
139
+ t.equal(
140
+ fastURI.serialize({ fragment: '😀/%3f#%GG' }),
141
+ '#%F0%9F%98%80/%3F%23%25GG',
142
+ 'fragment uses UTF-8 and does not double encode valid escapes'
143
+ )
144
+ t.equal(
145
+ fastURI.serialize({ query: 'Kſ' }),
146
+ '?%E2%84%AA%C5%BF',
147
+ 'Unicode characters that case-fold to ASCII are still UTF-8 encoded'
148
+ )
149
+ t.end()
150
+ })
151
+
152
+ test('parsed URI serialization remains stable', (t) => {
153
+ const input = 'x://user:pass@example.test/a%2Fb?x=a/b?c&y=%23#frag/a?b%23'
154
+ const serialized = fastURI.serialize(fastURI.parse(input))
155
+
156
+ t.equal(serialized, input, 'an already normalized parsed URI round-trips unchanged')
157
+ t.equal(
158
+ fastURI.serialize(fastURI.parse(serialized)),
159
+ serialized,
160
+ 'repeated parse and serialize cycles are stable'
161
+ )
162
+ t.end()
163
+ })