fast-uri 4.1.1 → 4.1.3

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
@@ -13,10 +13,13 @@ const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
13
13
  const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
14
14
 
15
15
  /** @type {(value: string) => boolean} */
16
- const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
16
+ const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u)
17
17
 
18
18
  /** @type {(value: string) => boolean} */
19
- const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/?]$/iu)
19
+ const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u)
20
+
21
+ /** @type {(value: string) => boolean} */
22
+ const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
20
23
 
21
24
  /**
22
25
  * @param {Array<string>} input
@@ -49,12 +52,14 @@ function stringArrayToHexStripped (input) {
49
52
  return acc
50
53
  }
51
54
 
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
- */
55
+ /** @type {(value: string) => boolean} */
56
+ const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
57
+
58
+ /** @type {(value: string) => boolean} */
59
+ const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
60
+
61
+ /** @type {(value: string) => boolean} */
62
+ const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
58
63
 
59
64
  /**
60
65
  * @param {string} value
@@ -62,89 +67,129 @@ function stringArrayToHexStripped (input) {
62
67
  */
63
68
  const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
64
69
 
70
+ /** @type {(value: string) => boolean} */
71
+ const nonSimpleDomainChars = RegExp.prototype.test.bind(/[^\d!"$&'()*+,\-.;=_`a-z{}~]/u)
72
+
73
+ /** @type {(value: string) => boolean} */
74
+ const numericLastLabel = RegExp.prototype.test.bind(/(?:^|\.)\d+$/u)
75
+
65
76
  /**
66
- * @param {Array<string>} buffer
77
+ * True when `new URL('http://' + domain).hostname` may differ from `domain`,
78
+ * i.e. when the caller must fall back to the WHATWG parser.
79
+ *
80
+ * Same purpose as `nonSimpleDomain`, but digits are allowed: for this character
81
+ * set `hostname` is the identity, *except* that an all-numeric final label is
82
+ * read as IPv4 shorthand ("1.2.3" -> "1.2.0.3", "127.1" -> "127.0.0.1",
83
+ * "a.b.1" throws), which is why `nonSimpleDomain` excludes digits outright.
84
+ * Rejecting that one shape lets the common `mail2.example.org` case stay on the
85
+ * fast path.
86
+ *
87
+ * @param {string} domain - must already be lowercased
67
88
  * @returns {boolean}
68
89
  */
69
- function consumeIsZone (buffer) {
70
- buffer.length = 0
71
- return true
90
+ function nonSimpleMailtoDomain (domain) {
91
+ return nonSimpleDomainChars(domain) || numericLastLabel(domain)
72
92
  }
73
93
 
74
94
  /**
75
- * @param {Array<string>} buffer
76
- * @param {Array<string>} address
77
- * @param {GetIPV6Result} output
95
+ * @param {string} zone
78
96
  * @returns {boolean}
79
97
  */
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
98
+ function isZoneIdentifier (zone) {
99
+ if (zone.length === 0) return false
100
+
101
+ for (let i = 0; i < zone.length; i++) {
102
+ if (isZoneCharacter(zone[i])) continue
103
+ if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
104
+ i += 2
105
+ continue
88
106
  }
89
- buffer.length = 0
107
+ return false
90
108
  }
109
+
91
110
  return true
92
111
  }
93
112
 
94
113
  /**
95
- * @param {string} input
96
- * @returns {GetIPV6Result}
114
+ * Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
115
+ * single zero hextet is left uncompressed. On ties the leftmost run wins.
116
+ *
117
+ * @param {string[]} hextets
118
+ * @returns {string}
97
119
  */
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
116
- }
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
120
+ function compressIPv6ZeroRun (hextets) {
121
+ let bestStart = -1
122
+ let bestLength = 0
123
+ let runStart = -1
124
+ let runLength = 0
125
+ for (let i = 0; i < hextets.length; i++) {
126
+ if (hextets[i] === '0') {
127
+ if (runStart === -1) runStart = i
128
+ runLength++
129
+ if (runLength > bestLength) {
130
+ bestLength = runLength
131
+ bestStart = runStart
125
132
  }
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
133
  } else {
133
- buffer.push(cursor)
134
- continue
134
+ runStart = -1
135
+ runLength = 0
135
136
  }
136
137
  }
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))
138
+
139
+ if (bestLength < 2) return hextets.join(':')
140
+
141
+ const head = hextets.slice(0, bestStart).join(':')
142
+ const tail = hextets.slice(bestStart + bestLength).join(':')
143
+ return head + '::' + tail
144
+ }
145
+
146
+ /**
147
+ * Validates an IPv6 address against the alternatives in RFC 3986 section
148
+ * 3.2.2 and returns the same address with leading hextet zeroes removed.
149
+ * An embedded IPv4 address counts as two hextets and is only valid at the end.
150
+ *
151
+ * @param {string} input
152
+ * @returns {string|undefined}
153
+ */
154
+ function normalizeIPv6Address (input) {
155
+ const compression = input.indexOf('::')
156
+ if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
157
+
158
+ const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
159
+ const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
160
+ if (compression !== -1) {
161
+ if (left.length === 1 && left[0] === '') left.length = 0
162
+ if (right.length === 1 && right[0] === '') right.length = 0
163
+ }
164
+
165
+ const parts = left.concat(right)
166
+ let hextetCount = 0
167
+ for (let i = 0; i < parts.length; i++) {
168
+ const part = parts[i]
169
+ if (part === '') return undefined
170
+
171
+ if (part.indexOf('.') !== -1) {
172
+ if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
173
+ hextetCount += 2
174
+ continue
144
175
  }
176
+
177
+ if (!isHextet(part)) return undefined
178
+ parts[i] = parseInt(part, 16).toString(16)
179
+ hextetCount++
145
180
  }
146
- output.address = address.join('')
147
- return output
181
+
182
+ if (compression === -1) {
183
+ if (hextetCount !== 8) return undefined
184
+ return compressIPv6ZeroRun(parts)
185
+ }
186
+ if (hextetCount >= 8) return undefined
187
+
188
+ // expand "::" then re-compress the longest run for a canonical result
189
+ const expanded = parts.slice(0, left.length)
190
+ for (let i = hextetCount; i < 8; i++) expanded.push('0')
191
+ for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
192
+ return compressIPv6ZeroRun(expanded)
148
193
  }
149
194
 
150
195
  /**
@@ -152,26 +197,49 @@ function getIPV6 (input) {
152
197
  * @property {string} host - The normalized host.
153
198
  * @property {string} [escapedHost] - The escaped host.
154
199
  * @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
200
+ * @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
201
+ * @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
155
202
  */
156
203
 
157
204
  /**
205
+ * Validates and normalizes a bracketed IP literal. Raw zone separators remain
206
+ * accepted for backwards compatibility, while encoded separators and zone
207
+ * contents follow RFC 6874.
208
+ *
158
209
  * @param {string} host
159
210
  * @returns {NormalizeIPv6Result}
160
211
  */
161
212
  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 }
213
+ const bracketed = host[0] === '[' && host[host.length - 1] === ']'
214
+ const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
215
+ if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
216
+
217
+ let input = bracketed ? host.slice(1, -1) : host
218
+ if (bracketed && isIPvFuture(input)) {
219
+ input = input.toLowerCase()
220
+ return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
221
+ }
222
+
223
+ if (findToken(input, ':') < 2) {
224
+ return { host, isIPV6: false, error: bracketed }
225
+ }
226
+
227
+ let zoneIdentifier = ''
228
+ const zoneSeparator = input.indexOf('%')
229
+ if (zoneSeparator !== -1) {
230
+ const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
231
+ zoneIdentifier = input.slice(zoneSeparator + separatorLength)
232
+ if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
233
+ input = input.slice(0, zoneSeparator)
234
+ }
235
+
236
+ const address = normalizeIPv6Address(input)
237
+ if (address === undefined) return { host, isIPV6: false, error: true }
238
+
239
+ return {
240
+ host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
241
+ escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
242
+ isIPV6: true
175
243
  }
176
244
  }
177
245
 
@@ -297,7 +365,7 @@ function reescapeHostDelimiters (host, isIP) {
297
365
 
298
366
  /**
299
367
  * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
300
- * Reserved delimiters such as `%2F` and `%2E` stay escaped.
368
+ * Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
301
369
  *
302
370
  * @param {string} input
303
371
  * @param {boolean} [decodeUnreserved=false]
@@ -438,6 +506,60 @@ function normalizePathEncoding (input) {
438
506
  return output
439
507
  }
440
508
 
509
+ /**
510
+ * Serializes a path without rewriting reserved data. Raw RFC 3986 path
511
+ * characters remain literal, valid escapes are preserved and uppercased, and
512
+ * everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the
513
+ * first segment must be escaped so the result cannot be parsed as a scheme.
514
+ *
515
+ * @param {string} input
516
+ * @param {boolean} [pathNoScheme=false]
517
+ * @returns {string}
518
+ */
519
+ function serializePathEncoding (input, pathNoScheme = false) {
520
+ let output = ''
521
+ let firstSegment = pathNoScheme && input[0] !== '/'
522
+
523
+ for (let i = 0; i < input.length; i++) {
524
+ const ch = input[i]
525
+ if (ch === '%' && i + 2 < input.length) {
526
+ const hex = input.slice(i + 1, i + 3)
527
+ if (isHexPair(hex)) {
528
+ output += '%' + hex.toUpperCase()
529
+ i += 2
530
+ continue
531
+ }
532
+ }
533
+
534
+ if (ch === '/') {
535
+ firstSegment = false
536
+ }
537
+
538
+ if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) {
539
+ output += ch
540
+ } else {
541
+ const code = input.charCodeAt(i)
542
+ if (code < 0x80) {
543
+ output += BYTE_HEX[code]
544
+ } else if (code < 0xD800 || code > 0xDFFF) {
545
+ output += percentEncodeNonAscii(code)
546
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
547
+ const low = input.charCodeAt(i + 1)
548
+ if (low >= 0xDC00 && low <= 0xDFFF) {
549
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
550
+ i++
551
+ } else {
552
+ output += percentEncodeNonAscii(0xFFFD)
553
+ }
554
+ } else {
555
+ output += percentEncodeNonAscii(0xFFFD)
556
+ }
557
+ }
558
+ }
559
+
560
+ return output
561
+ }
562
+
441
563
  /**
442
564
  * Normalizes the percent-encoding of a query or fragment component.
443
565
  *
@@ -495,6 +617,85 @@ function normalizeQueryFragmentEncoding (input) {
495
617
  return output
496
618
  }
497
619
 
620
+ /**
621
+ * Percent-encodes a URI component using its RFC 3986 literal character set.
622
+ * Existing valid escapes are preserved and normalized to uppercase hex.
623
+ *
624
+ * @param {string} input
625
+ * @param {(value: string) => boolean} isAllowed
626
+ * @returns {string}
627
+ */
628
+ function encodeComponent (input, isAllowed) {
629
+ let output = ''
630
+
631
+ for (let i = 0; i < input.length; i++) {
632
+ const ch = input[i]
633
+ if (ch === '%' && i + 2 < input.length) {
634
+ const hex = input.slice(i + 1, i + 3)
635
+ if (isHexPair(hex)) {
636
+ output += '%' + hex.toUpperCase()
637
+ i += 2
638
+ continue
639
+ }
640
+ }
641
+
642
+ if (isAllowed(ch)) {
643
+ output += ch
644
+ } else {
645
+ const code = input.charCodeAt(i)
646
+ if (code < 0x80) {
647
+ output += BYTE_HEX[code]
648
+ } else if (code < 0xD800 || code > 0xDFFF) {
649
+ output += percentEncodeNonAscii(code)
650
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
651
+ const low = input.charCodeAt(i + 1)
652
+ if (low >= 0xDC00 && low <= 0xDFFF) {
653
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
654
+ i++
655
+ } else {
656
+ output += percentEncodeNonAscii(0xFFFD)
657
+ }
658
+ } else {
659
+ output += percentEncodeNonAscii(0xFFFD)
660
+ }
661
+ }
662
+ }
663
+
664
+ return output
665
+ }
666
+
667
+ /**
668
+ * Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters.
669
+ * In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data.
670
+ *
671
+ * @param {string} input
672
+ * @returns {string}
673
+ */
674
+ function encodeUserinfo (input) {
675
+ return encodeComponent(input, isUserinfoCharacter)
676
+ }
677
+
678
+ /**
679
+ * Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be
680
+ * escaped because it would otherwise begin the fragment component.
681
+ *
682
+ * @param {string} input
683
+ * @returns {string}
684
+ */
685
+ function encodeQuery (input) {
686
+ return encodeComponent(input, isQueryFragmentCharacter)
687
+ }
688
+
689
+ /**
690
+ * Encodes fragment data using the RFC 3986 §3.5 grammar.
691
+ *
692
+ * @param {string} input
693
+ * @returns {string}
694
+ */
695
+ function encodeFragment (input) {
696
+ return encodeComponent(input, isQueryFragmentCharacter)
697
+ }
698
+
498
699
  /**
499
700
  * Escapes a component while preserving existing valid percent escapes.
500
701
  *
@@ -544,15 +745,21 @@ function recomposeAuthority (component) {
544
745
  const uriTokens = []
545
746
 
546
747
  if (component.userinfo !== undefined) {
547
- uriTokens.push(component.userinfo)
748
+ uriTokens.push(encodeUserinfo(component.userinfo))
548
749
  uriTokens.push('@')
549
750
  }
550
751
 
551
752
  if (component.host !== undefined) {
552
- let host = unescape(component.host)
753
+ let host = component.host
553
754
  if (!isIPv4(host)) {
554
- const ipV6res = normalizeIPv6(host)
555
- if (ipV6res.isIPV6 === true) {
755
+ let ipV6res = normalizeIPv6(host)
756
+ if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
757
+ // Decode only unreserved bytes, once. In particular, keep %25 encoded
758
+ // so it cannot introduce a second escape during recomposition.
759
+ host = normalizePercentEncoding(host, true)
760
+ ipV6res = normalizeIPv6(host)
761
+ }
762
+ if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
556
763
  host = `[${ipV6res.escapedHost}]`
557
764
  } else {
558
765
  host = reescapeHostDelimiters(host, false)
@@ -570,12 +777,19 @@ function recomposeAuthority (component) {
570
777
  };
571
778
 
572
779
  module.exports = {
780
+ BYTE_HEX,
781
+ percentEncodeNonAscii,
573
782
  nonSimpleDomain,
783
+ nonSimpleMailtoDomain,
574
784
  recomposeAuthority,
575
785
  reescapeHostDelimiters,
576
786
  normalizePercentEncoding,
577
787
  normalizePathEncoding,
788
+ serializePathEncoding,
578
789
  normalizeQueryFragmentEncoding,
790
+ encodeUserinfo,
791
+ encodeQuery,
792
+ encodeFragment,
579
793
  escapePreservingEscapes,
580
794
  removeDotSegments,
581
795
  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.1",
4
+ "version": "4.1.3",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -0,0 +1,105 @@
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('query and fragment serialization cannot inject component delimiters', (t) => {
28
+ const uri = fastURI.serialize({
29
+ scheme: 'x',
30
+ path: '/resource',
31
+ query: 'key=value#still-query',
32
+ fragment: 'first#still-fragment'
33
+ })
34
+
35
+ t.equal(
36
+ uri,
37
+ 'x:/resource?key=value%23still-query#first%23still-fragment',
38
+ 'raw hashes are encoded within their supplied components'
39
+ )
40
+
41
+ const reparsed = fastURI.parse(uri)
42
+ t.equal(reparsed.query, 'key=value%23still-query', 'hash remains query data')
43
+ t.equal(reparsed.fragment, 'first%23still-fragment', 'hash remains fragment data')
44
+ t.end()
45
+ })
46
+
47
+ test('component serializers preserve each RFC 3986 literal character set', (t) => {
48
+ const unreservedAndSubDelims = "AZaz09-._~!$&'()*+,;="
49
+ const userinfo = unreservedAndSubDelims + ':'
50
+ const queryOrFragment = unreservedAndSubDelims + ':@/?'
51
+
52
+ t.equal(
53
+ fastURI.serialize({ userinfo, host: 'example.test' }),
54
+ '//' + userinfo + '@example.test',
55
+ 'userinfo literals are preserved'
56
+ )
57
+ t.equal(
58
+ fastURI.serialize({ query: queryOrFragment }),
59
+ '?' + queryOrFragment,
60
+ 'query literals are preserved'
61
+ )
62
+ t.equal(
63
+ fastURI.serialize({ fragment: queryOrFragment }),
64
+ '#' + queryOrFragment,
65
+ 'fragment literals are preserved'
66
+ )
67
+ t.end()
68
+ })
69
+
70
+ test('component serializers preserve escapes and encode Unicode as UTF-8', (t) => {
71
+ t.equal(
72
+ fastURI.serialize({ userinfo: 'café/%2f%GG', host: 'example.test' }),
73
+ '//caf%C3%A9%2F%2F%25GG@example.test',
74
+ 'userinfo preserves valid escapes, uppercases hex, and escapes malformed percent data'
75
+ )
76
+ t.equal(
77
+ fastURI.serialize({ query: '日本/%2f#%GG' }),
78
+ '?%E6%97%A5%E6%9C%AC/%2F%23%25GG',
79
+ 'query uses UTF-8 and does not double encode valid escapes'
80
+ )
81
+ t.equal(
82
+ fastURI.serialize({ fragment: '😀/%3f#%GG' }),
83
+ '#%F0%9F%98%80/%3F%23%25GG',
84
+ 'fragment uses UTF-8 and does not double encode valid escapes'
85
+ )
86
+ t.equal(
87
+ fastURI.serialize({ query: 'Kſ' }),
88
+ '?%E2%84%AA%C5%BF',
89
+ 'Unicode characters that case-fold to ASCII are still UTF-8 encoded'
90
+ )
91
+ t.end()
92
+ })
93
+
94
+ test('parsed URI serialization remains stable', (t) => {
95
+ const input = 'x://user:pass@example.test/a%2Fb?x=a/b?c&y=%23#frag/a?b%23'
96
+ const serialized = fastURI.serialize(fastURI.parse(input))
97
+
98
+ t.equal(serialized, input, 'an already normalized parsed URI round-trips unchanged')
99
+ t.equal(
100
+ fastURI.serialize(fastURI.parse(serialized)),
101
+ serialized,
102
+ 'repeated parse and serialize cycles are stable'
103
+ )
104
+ t.end()
105
+ })
@@ -21,6 +21,36 @@ test('URI Equals', (t) => {
21
21
  t.end()
22
22
  })
23
23
 
24
+ test('URI Equals rejects malformed object input even when identical', (t) => {
25
+ const malformed = { scheme: 'http', host: 'example.com', port: 99999, path: '/x' }
26
+ t.equal(fn(malformed, malformed), false)
27
+ t.end()
28
+ })
29
+
30
+ test('URI Equals preserves case-sensitive components', (t) => {
31
+ const suite = [
32
+ { pair: ['http://example.com/Admin', 'http://example.com/admin'], result: false },
33
+ { pair: ['http://example.com/?token=SECRET', 'http://example.com/?token=secret'], result: false },
34
+ { pair: ['http://example.com/#Section', 'http://example.com/#section'], result: false },
35
+ { pair: ['http://User@example.com/', 'http://user@example.com/'], result: false },
36
+ { pair: ['urn:foo:CaseSensitive', 'urn:foo:casesensitive'], result: false },
37
+ { pair: ['ws://example.com/Chat', 'ws://example.com/chat'], result: false },
38
+ { pair: ['ws://example.com/?token=SECRET', 'ws://example.com/?token=secret'], result: false },
39
+ { pair: [{ scheme: 'http', host: 'example.com', path: '/Admin' }, { scheme: 'http', host: 'example.com', path: '/admin' }], result: false }
40
+ ]
41
+ runTest(t, suite)
42
+ t.end()
43
+ })
44
+
45
+ test('URI Equals folds normalized scheme and host case', (t) => {
46
+ const suite = [
47
+ { pair: ['HTTP://EXAMPLE.COM/resource', 'http://example.com/resource'], result: true },
48
+ { pair: [{ scheme: 'HTTP', host: 'EXAMPLE.COM', path: '/resource' }, { scheme: 'http', host: 'example.com', path: '/resource' }], result: true }
49
+ ]
50
+ runTest(t, suite)
51
+ t.end()
52
+ })
53
+
24
54
  // test('IRI Equals', (t) => {
25
55
  // // example from RFC 3987
26
56
  // t.equal(URI.equal('example://a/b/c/%7Bfoo%7D/ros\xE9', 'eXAMPLE://a/./b/../b/%63/%7bfoo%7d/ros%C3%A9', IRI_OPTION), true)
@@ -66,9 +96,7 @@ test('URN Equals', (t) => {
66
96
 
67
97
  runTest(t, suite)
68
98
 
69
- t.throws(() => {
70
- fn('urn:', 'urn:FOO:a123,456')
71
- }, 'URN without nid cannot be serialized')
99
+ t.equal(fn('urn:', 'urn:FOO:a123,456'), false, 'malformed URN fails equality safely')
72
100
 
73
101
  t.end()
74
102
  })
@@ -107,6 +135,18 @@ test('WSS Equal', (t) => {
107
135
  t.end()
108
136
  })
109
137
 
138
+ test('Mailto Equal normalizes domains without folding message data', (t) => {
139
+ const suite = [
140
+ { pair: ['mailto:user@EXAMPLE.ORG', 'mailto:user@example.org'], result: true },
141
+ { pair: ['mailto:User@example.org', 'mailto:user@example.org'], result: false },
142
+ { pair: ['mailto:user@example.org?subject=Hello', 'mailto:user@example.org?subject=hello'], result: false },
143
+ { pair: ['mailto:user@example.org?body=Hello', 'mailto:user@example.org?body=hello'], result: false },
144
+ { pair: ['mailto:user@example.org?x=Hello', 'mailto:user@example.org?x=hello'], result: false }
145
+ ]
146
+ runTest(t, suite)
147
+ t.end()
148
+ })
149
+
110
150
  test('URI Equals tolerates malformed fragments', (t) => {
111
151
  t.equal(
112
152
  fastURI.equal('http://example.com/#%E0%A4A', 'http://example.com/#%E0%A4A'),