fast-uri 4.1.3 → 4.1.5

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 CHANGED
@@ -328,6 +328,18 @@ function hasMalformedPercentEncoding (component) {
328
328
  return false
329
329
  }
330
330
 
331
+ /**
332
+ * Whether the host is a bracketed IP literal (RFC 3986 `IP-literal`).
333
+ * An unterminated `[` is not a literal, so it must still be validated as a
334
+ * reg-name instead of being waved through as an IP.
335
+ *
336
+ * @param {string} host
337
+ * @returns {boolean}
338
+ */
339
+ function isIPLiteral (host) {
340
+ return host[0] === '[' && host[host.length - 1] === ']'
341
+ }
342
+
331
343
  /**
332
344
  * @param {RegExpMatchArray} matches
333
345
  * @returns {boolean}
@@ -337,7 +349,7 @@ function hasMalformedComponentPercentEncoding (matches) {
337
349
  // compatibility. Their parsing is intentionally left to normalizeIPv6.
338
350
  const host = matches[4]
339
351
  return hasMalformedPercentEncoding(matches[3]) ||
340
- (host !== undefined && !(host[0] === '[' && host[host.length - 1] === ']') && hasMalformedPercentEncoding(host)) ||
352
+ (host !== undefined && !isIPLiteral(host) && hasMalformedPercentEncoding(host)) ||
341
353
  hasMalformedPercentEncoding(matches[6]) ||
342
354
  hasMalformedPercentEncoding(matches[7]) ||
343
355
  hasMalformedPercentEncoding(matches[8])
@@ -351,13 +363,19 @@ function hasMalformedComponentPercentEncoding (matches) {
351
363
  * @returns {boolean} whether host conversion failed
352
364
  */
353
365
  function canonicalizeHost (parsed, options, schemeHandler, isIP) {
366
+ const bracketedIPLiteral =
367
+ parsed.host &&
368
+ parsed.host[0] === '[' &&
369
+ parsed.host[parsed.host.length - 1] === ']'
370
+
354
371
  if (
355
372
  !options.unicodeSupport &&
356
373
  (!schemeHandler || !schemeHandler.unicodeSupport) &&
357
374
  parsed.host &&
358
- parsed.host[0] !== '[' &&
375
+ !isIPLiteral(parsed.host) &&
359
376
  (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
360
377
  isIP === false &&
378
+ !bracketedIPLiteral &&
361
379
  nonSimpleDomain(parsed.host)
362
380
  ) {
363
381
  try {
@@ -367,6 +385,7 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
367
385
  return true
368
386
  }
369
387
  }
388
+
370
389
  return false
371
390
  }
372
391
 
@@ -480,10 +499,11 @@ function parseWithStatus (uri, opts) {
480
499
  if (parsed.host) {
481
500
  const ipv4result = isIPv4(parsed.host)
482
501
  if (ipv4result === false) {
483
- const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
502
+ const bracketedIPLiteral = isIPLiteral(parsed.host)
503
+ const hasIPLiteralBracket = parsed.host.indexOf('[') !== -1 || parsed.host.indexOf(']') !== -1
484
504
  const ipv6result = normalizeIPv6(parsed.host)
485
505
  isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
486
- malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true
506
+ malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true)
487
507
  parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
488
508
 
489
509
  if (malformedIPLiteral) {
@@ -513,15 +533,21 @@ function parseWithStatus (uri, opts) {
513
533
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
514
534
 
515
535
  // convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
516
- malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
536
+ if (!malformedIPLiteral) {
537
+ malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
538
+ }
517
539
 
518
- if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
519
- if (uri.indexOf('%') !== -1) {
520
- if (parsed.host !== undefined && !malformedIPLiteral) {
521
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
522
- parsed.host = reescapeHostDelimiters(host, isIP)
523
- }
540
+ if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
541
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
542
+ if (!isIP) {
543
+ // Fold reg-name case after decoding unreserved octets. The second
544
+ // pass only restores uppercase hex in escapes that remain encoded.
545
+ host = normalizePercentEncoding(host.toLowerCase())
524
546
  }
547
+ parsed.host = reescapeHostDelimiters(host, isIP)
548
+ }
549
+
550
+ if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
525
551
  if (parsed.path) {
526
552
  parsed.path = normalizePathEncoding(parsed.path)
527
553
  }
package/lib/schemes.js CHANGED
@@ -495,23 +495,24 @@ function mailtoParse (component, options) {
495
495
  mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
496
496
  continue
497
497
  }
498
- const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
498
+ const name = decodeHex(eqIdx === -1 ? token : token.slice(0, eqIdx))
499
+ const normalizedName = name.toLowerCase()
499
500
  const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
500
- if (name === 'to') {
501
+ if (normalizedName === 'to') {
501
502
  const addrs = value.split(',')
502
503
  for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
503
504
  continue
504
505
  }
505
- if (name === 'subject') {
506
+ if (normalizedName === 'subject') {
506
507
  mailtoComponent.subject = decodeHex(value)
507
508
  continue
508
509
  }
509
- if (name === 'body') {
510
+ if (normalizedName === 'body') {
510
511
  mailtoComponent.body = decodeHex(value)
511
512
  continue
512
513
  }
513
514
  if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
514
- headers[decodeHex(name)] = decodeHex(value)
515
+ headers[name] = decodeHex(value)
515
516
  }
516
517
  if (headers !== null) mailtoComponent.headers = headers
517
518
  }
@@ -536,6 +537,18 @@ function mailtoParse (component, options) {
536
537
  return mailtoComponent
537
538
  }
538
539
 
540
+ /**
541
+ * Encodes a decoded mailto header name. Unlike `encodeWithAllow`, a percent
542
+ * sign is always data here, even when followed by two hex digits. Preserving
543
+ * such a sequence as an existing escape would decode the name again on the
544
+ * next parse and could turn a generic header into `to`, `subject`, or `body`.
545
+ * @param {string} name
546
+ * @returns {string}
547
+ */
548
+ function mailtoEncodeHeaderName (name) {
549
+ return encodeWithAllow(name.replace(/%/gu, '%25'), HFNAME)
550
+ }
551
+
539
552
  /**
540
553
  * @param {import('../types/index').URIComponent} component
541
554
  * @param {import('../types/index').Options} options
@@ -544,6 +557,31 @@ function mailtoParse (component, options) {
544
557
  function mailtoSerialize (component, options) {
545
558
  const mailtoComponent = component
546
559
  const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
560
+ const sourceHeaders = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
561
+ ? Object.assign(Object.create(null), mailtoComponent.headers)
562
+ : Object.create(null)
563
+ const headers = Object.create(null)
564
+
565
+ // Header field names are case-insensitive. Canonicalize the fields with
566
+ // dedicated component properties so callers cannot smuggle a second spelling
567
+ // through `headers`. As during parsing, all `to` fields are accumulated;
568
+ // repeated subject/body fields use the last value.
569
+ for (const name in sourceHeaders) {
570
+ const normalizedName = name.toLowerCase()
571
+ if (normalizedName === 'to') {
572
+ const addrs = String(sourceHeaders[name]).split(',')
573
+ for (let i = 0; i < addrs.length; i++) to.push(addrs[i])
574
+ } else if (normalizedName === 'subject' || normalizedName === 'body') {
575
+ headers[normalizedName] = sourceHeaders[name]
576
+ } else {
577
+ headers[name] = sourceHeaders[name]
578
+ }
579
+ }
580
+
581
+ if (mailtoComponent.subject !== undefined) headers.subject = mailtoComponent.subject
582
+ if (mailtoComponent.body !== undefined) headers.body = mailtoComponent.body
583
+ mailtoComponent.headers = headers
584
+
547
585
  if (to.length) {
548
586
  for (let i = 0; i < to.length; i++) {
549
587
  const addr = String(to[i])
@@ -570,21 +608,13 @@ function mailtoSerialize (component, options) {
570
608
  mailtoComponent.path = undefined
571
609
  }
572
610
 
573
- const headers = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
574
- ? Object.assign(Object.create(null), mailtoComponent.headers)
575
- : Object.create(null)
576
-
577
- if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
578
- if (mailtoComponent.body) headers.body = mailtoComponent.body
579
- mailtoComponent.headers = headers
580
-
581
- // `headers` has a null prototype and was filled via Object.assign, so every
582
- // enumerable key is an own property and no hasOwnProperty guard is needed.
611
+ // `headers` has a null prototype, so every enumerable key is an own property
612
+ // and no hasOwnProperty guard is needed.
583
613
  let query = ''
584
614
  let count = 0
585
615
  for (const name in headers) {
586
616
  if (count++ !== 0) query += '&'
587
- query += encodeWithAllow(name, HFNAME) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
617
+ query += mailtoEncodeHeaderName(name) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
588
618
  }
589
619
 
590
620
  if (count !== 0) {
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
 
@@ -769,8 +772,12 @@ function recomposeAuthority (component) {
769
772
  }
770
773
 
771
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
+ }
772
779
  uriTokens.push(':')
773
- uriTokens.push(String(component.port))
780
+ uriTokens.push(port)
774
781
  }
775
782
 
776
783
  return uriTokens.length ? uriTokens.join('') : undefined
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.3",
4
+ "version": "4.1.5",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -24,6 +24,64 @@ test('userinfo serialization cannot terminate the authority', (t) => {
24
24
  t.end()
25
25
  })
26
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
+
27
85
  test('query and fragment serialization cannot inject component delimiters', (t) => {
28
86
  const uri = fastURI.serialize({
29
87
  scheme: 'x',
@@ -88,3 +88,67 @@ test('valid IPv6, IPvFuture, embedded IPv4, and zone forms normalize safely', (t
88
88
  }
89
89
  t.end()
90
90
  })
91
+
92
+ test('IPv6 zone identifiers are validated correctly', (t) => {
93
+ const valid = [
94
+ 'http://[fe80::1%25eth0]/',
95
+ 'http://[fe80::a%en1]/',
96
+ 'http://[fe80::a%25eth%2D0]/'
97
+ ]
98
+
99
+ for (const uri of valid) {
100
+ const parsed = fastURI.parse(uri)
101
+
102
+ t.equal(parsed.error, undefined, `${uri} parses without error`)
103
+ }
104
+
105
+ const malformed = [
106
+ 'http://[fe80::1%25]/',
107
+ 'http://[fe80::1%25eth 0]/',
108
+ 'http://[fe80::1%25eth%ZZ]/',
109
+ 'http://[fe80::1%25K]/'
110
+ ]
111
+
112
+ for (const uri of malformed) {
113
+ const parsed = fastURI.parse(uri)
114
+
115
+ t.equal(parsed.error, HOST_ERROR, `${uri} is rejected`)
116
+ t.equal(fastURI.normalize(uri), uri, `${uri} is not rewritten`)
117
+ t.equal(fastURI.equal(uri, uri), false, `${uri} is not comparable`)
118
+ }
119
+ t.end()
120
+ })
121
+
122
+ test('hosts with unbalanced or misplaced IP-literal brackets are rejected', (t) => {
123
+ const malformed = [
124
+ 'http://[fe80',
125
+ 'http://[',
126
+ 'http://[not-an-ip',
127
+ 'http://[\u65e5\u672c',
128
+ 'http://user@[@127.0.0.1:8123/admin',
129
+ 'http://user@]127.0.0.1:8123/admin',
130
+ 'http://user@prefix[@127.0.0.1:8123/admin',
131
+ 'http://user@prefix]@127.0.0.1:8123/admin'
132
+ ]
133
+ const modes = [
134
+ ['default', undefined],
135
+ ['Unicode', { unicodeSupport: true }]
136
+ ]
137
+
138
+ for (const [mode, options] of modes) {
139
+ for (const uri of malformed) {
140
+ const parsed = fastURI.parse(uri, options)
141
+ const message = `${mode} mode rejects ${uri}`
142
+
143
+ t.equal(parsed.error, HOST_ERROR, `parse ${message}`)
144
+ t.equal(fastURI.normalize(uri, options), uri, `normalize preserves ${uri} in ${mode} mode`)
145
+ t.equal(fastURI.equal(uri, uri, options), false, `equal ${message}`)
146
+ t.throws(
147
+ () => fastURI.resolve('http://example.com/', uri, options),
148
+ /URI host is malformed\./,
149
+ `resolve ${message}`
150
+ )
151
+ }
152
+ }
153
+ t.end()
154
+ })
@@ -0,0 +1,151 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ function encodeCharacter (value, index, lowercaseHex) {
7
+ let hex = value.charCodeAt(index).toString(16)
8
+ if (!lowercaseHex) hex = hex.toUpperCase()
9
+ return value.slice(0, index) + '%' + hex + value.slice(index + 1)
10
+ }
11
+
12
+ function mailtoState (component) {
13
+ return {
14
+ to: component.to,
15
+ subject: component.subject,
16
+ body: component.body,
17
+ headers: component.headers && { ...component.headers }
18
+ }
19
+ }
20
+
21
+ test('mailto parsing classifies decoded, case-insensitive reserved field names', (t) => {
22
+ const cases = [
23
+ { name: 'to', value: 'attacker@evil.test', property: 'to' },
24
+ { name: 'subject', value: 'injected subject', property: 'subject' },
25
+ { name: 'body', value: 'injected body', property: 'body' }
26
+ ]
27
+
28
+ for (const entry of cases) {
29
+ const variants = new Set([entry.name.toUpperCase()])
30
+ for (let i = 0; i < entry.name.length; i++) {
31
+ variants.add(encodeCharacter(entry.name, i, false))
32
+ variants.add(encodeCharacter(entry.name, i, true))
33
+ }
34
+
35
+ for (const name of variants) {
36
+ const parsed = fastURI.parse(`mailto:alice@example.test?${name}=${entry.value}`)
37
+ if (entry.property === 'to') {
38
+ t.deepEqual(parsed.to, ['alice@example.test', entry.value], name + ' is classified as a recipient')
39
+ } else {
40
+ t.equal(parsed[entry.property], entry.value, name + ' is classified as ' + entry.property)
41
+ }
42
+ t.equal(parsed.headers, undefined, name + ' is not exposed as a generic header')
43
+
44
+ const reparsed = fastURI.parse(fastURI.serialize(parsed))
45
+ t.deepEqual(mailtoState(reparsed), mailtoState(parsed), name + ' keeps its meaning after a roundtrip')
46
+ }
47
+ }
48
+
49
+ t.end()
50
+ })
51
+
52
+ test('mailto generic field names are decoded exactly once across roundtrips', (t) => {
53
+ const cases = [
54
+ ['%2574o', '%74o'],
55
+ ['%2573ubject', '%73ubject'],
56
+ ['%2562ody', '%62ody'],
57
+ ['x%2520y', 'x%20y'],
58
+ ['%26to', '&to'],
59
+ ['%25GGto', '%GGto']
60
+ ]
61
+
62
+ for (const [encodedName, decodedName] of cases) {
63
+ let serialized = `mailto:alice@example.test?${encodedName}=value`
64
+ for (let round = 0; round < 3; round++) {
65
+ const parsed = fastURI.parse(serialized)
66
+ t.deepEqual({ ...parsed.headers }, { [decodedName]: 'value' }, encodedName + ' stays generic in round ' + round)
67
+ t.deepEqual(parsed.to, ['alice@example.test'], encodedName + ' does not add a recipient in round ' + round)
68
+ t.equal(parsed.subject, undefined, encodedName + ' does not set a subject in round ' + round)
69
+ t.equal(parsed.body, undefined, encodedName + ' does not set a body in round ' + round)
70
+ serialized = fastURI.serialize(parsed)
71
+ }
72
+ t.equal(serialized, `mailto:alice@example.test?${encodedName}=value`, encodedName + ' serializes idempotently')
73
+ }
74
+
75
+ t.equal(
76
+ fastURI.serialize({ scheme: 'mailto', to: ['alice@example.test'], headers: { '%74o': 'value' } }),
77
+ 'mailto:alice@example.test?%2574o=value',
78
+ 'a percent sequence in a decoded component key is encoded as data'
79
+ )
80
+ t.end()
81
+ })
82
+
83
+ test('mailto serialization canonicalizes reserved generic header names', (t) => {
84
+ const serialized = fastURI.serialize({
85
+ scheme: 'mailto',
86
+ to: ['alice@example.test'],
87
+ subject: 'safe subject',
88
+ body: 'safe body',
89
+ headers: {
90
+ TO: 'attacker@evil.test',
91
+ Subject: 'hidden subject',
92
+ BODY: 'hidden body',
93
+ cc: 'copy@example.test'
94
+ }
95
+ })
96
+
97
+ t.equal(
98
+ serialized,
99
+ 'mailto:alice@example.test,attacker@evil.test?subject=safe%20subject&body=safe%20body&cc=copy@example.test',
100
+ 'dedicated properties override header variants and all recipients are visible in the path'
101
+ )
102
+
103
+ const parsed = fastURI.parse(serialized)
104
+ t.deepEqual(parsed.to, ['alice@example.test', 'attacker@evil.test'], 'canonical recipients are classified consistently')
105
+ t.equal(parsed.subject, 'safe subject', 'the dedicated subject wins')
106
+ t.equal(parsed.body, 'safe body', 'the dedicated body wins')
107
+ t.deepEqual({ ...parsed.headers }, { cc: 'copy@example.test' }, 'only generic headers remain generic')
108
+
109
+ const empty = fastURI.serialize({
110
+ scheme: 'mailto',
111
+ to: ['alice@example.test'],
112
+ subject: '',
113
+ body: '',
114
+ headers: { Subject: 'hidden subject', BODY: 'hidden body' }
115
+ })
116
+ t.equal(empty, 'mailto:alice@example.test?subject=&body=', 'explicit empty properties override header variants')
117
+ const emptyParsed = fastURI.parse(empty)
118
+ t.equal(emptyParsed.subject, '', 'an empty subject round-trips')
119
+ t.equal(emptyParsed.body, '', 'an empty body round-trips')
120
+ t.end()
121
+ })
122
+
123
+ test('mailto duplicate reserved fields have deterministic parsing semantics', (t) => {
124
+ const parsed = fastURI.parse(
125
+ 'mailto:alice@example.test?subject=first&%53UBJECT=last&body=first&%42ODY=last&to=one@example.test&%54O=two@example.test'
126
+ )
127
+
128
+ t.equal(parsed.subject, 'last', 'the last subject wins')
129
+ t.equal(parsed.body, 'last', 'the last body wins')
130
+ t.deepEqual(
131
+ parsed.to,
132
+ ['alice@example.test', 'one@example.test', 'two@example.test'],
133
+ 'all recipient fields are accumulated'
134
+ )
135
+ t.equal(parsed.headers, undefined, 'no reserved spelling remains generic')
136
+ t.end()
137
+ })
138
+
139
+ test('encoded reserved field names expose decoded control characters immediately', (t) => {
140
+ const input = 'mailto:alice@example.test?%73ubject=hello%0D%0Aworld'
141
+ const parsed = fastURI.parse(input)
142
+
143
+ t.equal(parsed.subject, 'hello\r\nworld', 'the first parse exposes CRLF in the dedicated property')
144
+ t.equal(parsed.headers, undefined, 'the subject is not hidden as a generic header')
145
+ t.equal(
146
+ fastURI.parse(fastURI.serialize(parsed)).subject,
147
+ parsed.subject,
148
+ 'the decoded subject remains stable after a roundtrip'
149
+ )
150
+ t.end()
151
+ })
@@ -126,6 +126,44 @@ test('hostname normalization decodes only current unreserved escapes', (t) => {
126
126
  t.end()
127
127
  })
128
128
 
129
+ test('scheme-relative hostname normalization folds decoded ASCII case', (t) => {
130
+ const encodedHost = '//%41.com'
131
+ const normalizedHost = fastURI.normalize(encodedHost)
132
+ const encodedMetadata = '//%4Detadata.internal/private'
133
+ const literalMetadata = '//metadata.internal/private'
134
+
135
+ t.equal(fastURI.parse(encodedHost).host, 'a.com', 'parse folds an encoded uppercase host letter')
136
+ t.equal(normalizedHost, '//a.com', 'normalize emits a lowercase host')
137
+ t.equal(fastURI.normalize(normalizedHost), normalizedHost, 'host normalization is idempotent')
138
+ t.equal(fastURI.equal(encodedHost, '//a.com'), true, 'encoded and literal host spellings compare equal')
139
+ t.equal(fastURI.equal(encodedMetadata, literalMetadata), true, 'encoded metadata host compares equal')
140
+ t.equal(
141
+ fastURI.resolve('x://trusted.example/', encodedMetadata),
142
+ fastURI.resolve('x://trusted.example/', literalMetadata),
143
+ 'encoded and literal metadata hosts resolve identically'
144
+ )
145
+ t.equal(
146
+ fastURI.normalize('//example.com%2fpath'),
147
+ '//example.com%2Fpath',
148
+ 'reserved host escapes remain encoded with uppercase hex'
149
+ )
150
+ t.equal(fastURI.normalize('//%2541.com'), '//%2541.com', 'nested host escapes are not decoded twice')
151
+ t.equal(fastURI.equal('//User@%41.com/path', '//user@a.com/path'), false, 'userinfo remains case-sensitive')
152
+ t.equal(fastURI.equal('//%41.com/Path', '//a.com/path'), false, 'path remains case-sensitive')
153
+ t.equal(fastURI.equal('//%41.com/?Token=Value', '//a.com/?token=value'), false, 'query remains case-sensitive')
154
+ t.end()
155
+ })
156
+
157
+ test('host normalization applies to schemes that skip path normalization', (t) => {
158
+ const encodedHost = 'mailto://%41.com'
159
+ const literalHost = 'mailto://a.com'
160
+
161
+ t.equal(fastURI.parse(encodedHost).host, fastURI.parse(literalHost).host, 'parse results have consistent hosts')
162
+ t.equal(fastURI.normalize(encodedHost), fastURI.normalize(literalHost), 'normalize results are consistent')
163
+ t.equal(fastURI.equal(encodedHost, literalHost), true, 'encoded and literal hosts compare equal')
164
+ t.end()
165
+ })
166
+
129
167
  test('host conversion failures are not treated as comparable URLs', (t) => {
130
168
  const malformedHost = 'http://trusted.test%2540evil.test/'
131
169