fast-uri 4.0.1 → 4.1.0

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
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, normalizeQueryFragmentEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
5
5
 
6
6
  /**
@@ -325,12 +325,11 @@ function parseWithStatus (uri, opts) {
325
325
  if (parsed.path) {
326
326
  parsed.path = normalizePathEncoding(parsed.path)
327
327
  }
328
+ if (parsed.query) {
329
+ parsed.query = normalizeQueryFragmentEncoding(parsed.query)
330
+ }
328
331
  if (parsed.fragment) {
329
- try {
330
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment))
331
- } catch {
332
- parsed.error = parsed.error || 'URI malformed'
333
- }
332
+ parsed.fragment = normalizeQueryFragmentEncoding(parsed.fragment)
334
333
  }
335
334
  }
336
335
 
package/lib/utils.js CHANGED
@@ -15,6 +15,9 @@ const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
15
15
  /** @type {(value: string) => boolean} */
16
16
  const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
17
17
 
18
+ /** @type {(value: string) => boolean} */
19
+ const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/?]$/iu)
20
+
18
21
  /**
19
22
  * @param {Array<string>} input
20
23
  * @returns {string}
@@ -218,7 +221,7 @@ function removeDotSegments (path) {
218
221
  continue
219
222
  }
220
223
  } else if (input[0] === '/') {
221
- if (input[1] === '.' || input[1] === '/') {
224
+ if (input[1] === '.') {
222
225
  output.push('/')
223
226
  break
224
227
  }
@@ -435,6 +438,63 @@ function normalizePathEncoding (input) {
435
438
  return output
436
439
  }
437
440
 
441
+ /**
442
+ * Normalizes the percent-encoding of a query or fragment component.
443
+ *
444
+ * Like `normalizePathEncoding`, but uses the query/fragment character set
445
+ * (which additionally allows `?`) and decodes `.` since it has no dot-segment
446
+ * meaning outside of a path.
447
+ *
448
+ * @param {string} input
449
+ * @returns {string}
450
+ */
451
+ function normalizeQueryFragmentEncoding (input) {
452
+ let output = ''
453
+
454
+ for (let i = 0; i < input.length; i++) {
455
+ const ch = input[i]
456
+ if (ch === '%' && i + 2 < input.length) {
457
+ const hex = input.slice(i + 1, i + 3)
458
+ if (isHexPair(hex)) {
459
+ const normalizedHex = hex.toUpperCase()
460
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
461
+
462
+ if (isUnreserved(decoded)) {
463
+ output += decoded
464
+ } else {
465
+ output += '%' + normalizedHex
466
+ }
467
+
468
+ i += 2
469
+ continue
470
+ }
471
+ }
472
+
473
+ if (isQueryFragmentCharacter(ch)) {
474
+ output += ch
475
+ } else {
476
+ const code = input.charCodeAt(i)
477
+ if (code < 0x80) {
478
+ output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
479
+ } else if (code < 0xD800 || code > 0xDFFF) {
480
+ output += percentEncodeNonAscii(code)
481
+ } else if (code <= 0xDBFF && i + 1 < input.length) {
482
+ const low = input.charCodeAt(i + 1)
483
+ if (low >= 0xDC00 && low <= 0xDFFF) {
484
+ output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
485
+ i++
486
+ } else {
487
+ output += percentEncodeNonAscii(0xFFFD)
488
+ }
489
+ } else {
490
+ output += percentEncodeNonAscii(0xFFFD)
491
+ }
492
+ }
493
+ }
494
+
495
+ return output
496
+ }
497
+
438
498
  /**
439
499
  * Escapes a component while preserving existing valid percent escapes.
440
500
  *
@@ -515,6 +575,7 @@ module.exports = {
515
575
  reescapeHostDelimiters,
516
576
  normalizePercentEncoding,
517
577
  normalizePathEncoding,
578
+ normalizeQueryFragmentEncoding,
518
579
  escapePreservingEscapes,
519
580
  removeDotSegments,
520
581
  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.0.1",
4
+ "version": "4.1.0",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -150,10 +150,12 @@ test('URI parse', (t) => {
150
150
  t.equal(components.query, undefined, 'query')
151
151
  t.equal(components.fragment, '%0D', 'fragment')
152
152
 
153
- // malformed percent-encoded fragment must not throw
153
+ // a fragment whose decoded bytes are not valid UTF-8 is still valid
154
+ // percent-encoding (RFC 3986 §2.1 is byte-level), so it is preserved as-is
155
+ // rather than being flagged as malformed
154
156
  components = fastURI.parse('http://example.com/#%E0%A4A')
155
- t.equal(components.error, 'URI malformed', 'malformed fragment errors')
156
- t.equal(components.fragment, '%E0%A4A', 'malformed fragment is preserved')
157
+ t.equal(components.error, undefined, 'valid percent-encoding is not flagged as malformed')
158
+ t.equal(components.fragment, '%E0%A4A', 'fragment is preserved')
157
159
 
158
160
  // all
159
161
  components = fastURI.parse('uri://user:pass@example.com:123/one/two.three?q1=a1&q2=a2#body')
@@ -0,0 +1,33 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ test('query percent-encoding is normalized (RFC 3986 §6.2.2)', (t) => {
7
+ // hex digits are uppercased, like the path component already does
8
+ t.equal(fastURI.normalize('x://h/p?a=%2a'), 'x://h/p?a=%2A', 'uppercases hex in query')
9
+ // unreserved characters are decoded
10
+ t.equal(fastURI.normalize('x://h/p?a=%7e%2e'), 'x://h/p?a=~.', 'decodes unreserved in query')
11
+ // reserved characters stay percent-encoded
12
+ t.equal(fastURI.normalize('x://h/p?a=%2F'), 'x://h/p?a=%2F', 'keeps reserved encoded in query')
13
+ // raw characters that are not allowed are percent-encoded
14
+ t.equal(fastURI.normalize('x://h/p?a b'), 'x://h/p?a%20b', 'encodes a raw space in query')
15
+ t.equal(fastURI.normalize('x://h/p?café'), 'x://h/p?caf%C3%A9', 'encodes raw non-ASCII in query')
16
+ // `?` is allowed unencoded in a query
17
+ t.equal(fastURI.normalize('x://h/p?a?b'), 'x://h/p?a?b', 'keeps `?` raw in query')
18
+
19
+ t.end()
20
+ })
21
+
22
+ test('fragment percent-encoding is normalized without decoding reserved characters', (t) => {
23
+ // reserved characters must NOT be decoded — `%2F` is not `/`
24
+ t.equal(fastURI.normalize('x://h/p#a%2Fb%2A'), 'x://h/p#a%2Fb%2A', 'keeps reserved encoded in fragment')
25
+ // hex is uppercased and unreserved is decoded
26
+ t.equal(fastURI.normalize('x://h/p#a%2a%7e'), 'x://h/p#a%2A~', 'uppercases hex and decodes unreserved in fragment')
27
+ // raw characters are still encoded
28
+ t.equal(fastURI.normalize('x://h/p#a b'), 'x://h/p#a%20b', 'encodes a raw space in fragment')
29
+ // `?` and `/` are allowed unencoded in a fragment
30
+ t.equal(fastURI.normalize('x://h/p#f?x/y'), 'x://h/p#f?x/y', 'keeps `?` and `/` raw in fragment')
31
+
32
+ t.end()
33
+ })
package/test/util.test.js CHANGED
@@ -29,6 +29,15 @@ test('removeDotSegments', (t) => {
29
29
  // https://github.com/fastify/fast-uri/issues/139
30
30
  testCases.push(['WS:/WS://1305G130505:1&%0D:1&C(XXXXX*)))))))XXX130505:UUVUaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaa13a',
31
31
  'WS:/WS://1305G130505:1&%0D:1&C(XXXXX*)))))))XXX130505:UUVUaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaa13a'])
32
+ // trailing empty segments must be preserved: RFC 3986 5.2.4 only removes
33
+ // complete "." / ".." segments, never an empty segment
34
+ testCases.push(['//', '//'])
35
+ testCases.push(['/foo//', '/foo//'])
36
+ testCases.push(['/a/b//', '/a/b//'])
37
+ testCases.push(['/x//y//z', '/x//y//z'])
38
+ // "." / ".." segments still collapse
39
+ testCases.push(['/a/b/c/./../../g', '/a/g'])
40
+ testCases.push(['/.', '/'])
32
41
 
33
42
  t.plan(testCases.length)
34
43