fast-uri 4.2.1 → 4.2.2

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.
@@ -0,0 +1,24 @@
1
+ name: Release the package
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ semver:
7
+ description: 'Release bump type'
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+
15
+ permissions: {}
16
+
17
+ jobs:
18
+ release:
19
+ permissions:
20
+ id-token: write # required for npm provenance via OIDC
21
+ contents: write # required to push the release commit and the tag
22
+ uses: fastify/workflows/.github/workflows/reusable-release.yml@b1a11bce713bc857e52b2231fec3c89071ad3135
23
+ with:
24
+ semver: ${{ inputs.semver }}
package/README.md CHANGED
@@ -13,7 +13,7 @@ Dependency-free RFC 3986 URI toolbox.
13
13
 
14
14
  All of the above functions can accept an additional options argument that is an object that can contain one or more of the following properties:
15
15
 
16
- Malformed authorities and out-of-range ports are reported through the parsed component's `error` field. `normalize()` leaves malformed string inputs unchanged, and `equal()` returns `false` when either string input is malformed.
16
+ Malformed authorities, out-of-range ports, and raw leading or trailing C0 controls or spaces (U+0000–U+0020) are reported through the parsed component's `error` field. `normalize()` leaves these malformed string inputs unchanged, and `equal()` returns `false`. `resolve()` rejects them in either input and rejects HTTP(S) results with an empty host. Percent-encoded and interior component data remain valid.
17
17
 
18
18
  * `scheme` (string)
19
19
  Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
3
+ const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, encodeHost, isIPv4, nonSimpleDomain } = require('./lib/utils')
4
4
  const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
5
5
 
6
6
  const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u
@@ -44,6 +44,7 @@ function resolve (baseURI, relativeURI, options) {
44
44
  const {
45
45
  parsed: baseParsed,
46
46
  malformedAuthorityOrPort: baseMalformed,
47
+ malformedReferenceEdge: baseMalformedReferenceEdge,
47
48
  malformedPercentEncoding: baseMalformedPercentEncoding,
48
49
  malformedSchemeSpecific: baseMalformedSchemeSpecific,
49
50
  malformedHost: baseMalformedHost,
@@ -52,6 +53,7 @@ function resolve (baseURI, relativeURI, options) {
52
53
  const {
53
54
  parsed: relativeParsed,
54
55
  malformedAuthorityOrPort: relativeMalformed,
56
+ malformedReferenceEdge: relativeMalformedReferenceEdge,
55
57
  malformedPercentEncoding: relativeMalformedPercentEncoding,
56
58
  malformedSchemeSpecific: relativeMalformedSchemeSpecific,
57
59
  malformedHost: relativeMalformedHost,
@@ -60,6 +62,8 @@ function resolve (baseURI, relativeURI, options) {
60
62
  if (
61
63
  baseMalformed ||
62
64
  relativeMalformed ||
65
+ baseMalformedReferenceEdge ||
66
+ relativeMalformedReferenceEdge ||
63
67
  baseMalformedPercentEncoding ||
64
68
  relativeMalformedPercentEncoding ||
65
69
  baseMalformedSchemeSpecific ||
@@ -74,6 +78,14 @@ function resolve (baseURI, relativeURI, options) {
74
78
  const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true)
75
79
  const resolvedSchemeHandler = getSchemeHandler((options && options.scheme) || resolved.scheme)
76
80
  const resolvedHost = resolved.host
81
+ // Inputs are parsed scheme-neutrally during resolution, so enforce the final
82
+ // HTTP scheme's required host before serializing an invalid HTTP URI.
83
+ if (
84
+ (resolved.scheme === 'http' || resolved.scheme === 'https') &&
85
+ !resolvedHost
86
+ ) {
87
+ throw new Error('HTTP URIs must have a host.')
88
+ }
77
89
  const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
78
90
  (isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
79
91
  canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
@@ -86,6 +98,9 @@ function resolve (baseURI, relativeURI, options) {
86
98
  throw new Error(resolved.error)
87
99
  }
88
100
  schemelessOptions.skipEscape = true
101
+ if (resolvedSchemeHandler && resolvedSchemeHandler.absolutePath) {
102
+ schemelessOptions.absolutePath = true
103
+ }
89
104
  return serialize(resolved, schemelessOptions)
90
105
  }
91
106
 
@@ -127,7 +142,10 @@ function resolveComponent (base, relative, options, skipNormalization) {
127
142
  target.userinfo = relative.userinfo
128
143
  copyHost(target, relative)
129
144
  target.port = relative.port
130
- target.path = removeDotSegments(relative.path || '')
145
+ const relativeSchemeHandler = getSchemeHandler(relative.scheme)
146
+ target.path = relativeSchemeHandler && relativeSchemeHandler.absolutePath
147
+ ? relative.path || ''
148
+ : removeDotSegments(relative.path || '')
131
149
  target.query = relative.query
132
150
  } else {
133
151
  if (relative.userinfo !== undefined || relative.host !== undefined || relative.port !== undefined) {
@@ -243,7 +261,8 @@ function serialize (cmpts, opts) {
243
261
  uriTokens.push(component.scheme, ':')
244
262
  }
245
263
 
246
- const authority = recomposeAuthority(component)
264
+ const allowNonAsciiHost = options.unicodeSupport === true || Boolean(schemeHandler && schemeHandler.unicodeSupport)
265
+ const authority = recomposeAuthority(component, allowNonAsciiHost)
247
266
  if (authority !== undefined) {
248
267
  if (options.reference !== 'suffix') {
249
268
  uriTokens.push('//')
@@ -409,7 +428,7 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
409
428
  /**
410
429
  * @param {string} uri
411
430
  * @param {import('./types/index').Options} [opts]
412
- * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
431
+ * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedReferenceEdge: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
413
432
  */
414
433
  function parseWithStatus (uri, opts) {
415
434
  const options = Object.assign({}, opts)
@@ -425,6 +444,12 @@ function parseWithStatus (uri, opts) {
425
444
  }
426
445
 
427
446
  let malformedAuthorityOrPort = false
447
+ // Check the caller's original reference before suffix mode prepends a scheme
448
+ // or authority marker and turns its leading edge into interior data.
449
+ const malformedReferenceEdge = uri.length !== 0 && (
450
+ uri.charCodeAt(0) <= 0x20 ||
451
+ uri.charCodeAt(uri.length - 1) <= 0x20
452
+ )
428
453
  let malformedPercentEncoding = false
429
454
  let malformedSchemeSpecific = false
430
455
  let malformedHost = false
@@ -475,6 +500,10 @@ function parseWithStatus (uri, opts) {
475
500
  }
476
501
  }
477
502
 
503
+ if (malformedReferenceEdge) {
504
+ parsed.error = parsed.error || 'URI must not contain leading or trailing C0 controls or spaces.'
505
+ }
506
+
478
507
  const matches = uri.match(URI_PARSE)
479
508
 
480
509
  if (matches) {
@@ -564,14 +593,24 @@ function parseWithStatus (uri, opts) {
564
593
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
565
594
  }
566
595
 
567
- if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
568
- let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
569
- if (!isIP) {
596
+ if (parsed.host !== undefined && !malformedIPLiteral) {
597
+ let host = parsed.host
598
+ if (uri.indexOf('%') !== -1 && !isIP) {
599
+ host = normalizePercentEncoding(host, true)
570
600
  // Fold reg-name case after decoding unreserved octets. The second
571
601
  // pass only restores uppercase hex in escapes that remain encoded.
572
602
  host = normalizePercentEncoding(host.toLowerCase())
573
603
  }
574
- parsed.host = reescapeHostDelimiters(host, isIP)
604
+ // Resolution parses both inputs with the internal `null` scheme before
605
+ // the effective scheme is known. Preserve raw host characters for that
606
+ // intermediate step so the final scheme can apply domain canonicalization;
607
+ // the resolved authority is encoded when it is serialized.
608
+ if (options.scheme !== 'null') {
609
+ const allowNonAsciiHost = options.unicodeSupport === true || Boolean(schemeHandler && schemeHandler.unicodeSupport)
610
+ parsed.host = encodeHost(host, isIP, allowNonAsciiHost)
611
+ } else {
612
+ parsed.host = host
613
+ }
575
614
  }
576
615
 
577
616
  if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
@@ -596,7 +635,7 @@ function parseWithStatus (uri, opts) {
596
635
  } else {
597
636
  parsed.error = parsed.error || 'URI can not be parsed.'
598
637
  }
599
- return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
638
+ return { parsed, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
600
639
  }
601
640
 
602
641
  /**
@@ -620,13 +659,14 @@ function normalizeString (uri, opts) {
620
659
  /**
621
660
  * @param {string} uri
622
661
  * @param {import('./types/index').Options} [opts]
623
- * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
662
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedReferenceEdge: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
624
663
  */
625
664
  function normalizeStringWithStatus (uri, opts) {
626
- const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
665
+ const { parsed, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
627
666
  return {
628
- normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
667
+ normalized: malformedAuthorityOrPort || malformedReferenceEdge || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
629
668
  malformedAuthorityOrPort,
669
+ malformedReferenceEdge,
630
670
  malformedPercentEncoding,
631
671
  malformedSchemeSpecific,
632
672
  malformedHost,
@@ -650,8 +690,8 @@ function normalizeComparableURI (uri, opts) {
650
690
  } catch {
651
691
  return undefined
652
692
  }
653
- const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
654
- return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
693
+ const { normalized, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
694
+ return malformedAuthorityOrPort || malformedReferenceEdge || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
655
695
  }
656
696
 
657
697
  const fastUri = {
package/lib/schemes.js CHANGED
@@ -230,7 +230,10 @@ const urn = /** @type {SchemeHandler} */ ({
230
230
  scheme: 'urn',
231
231
  parse: urnParse,
232
232
  serialize: urnSerialize,
233
- skipNormalize: true
233
+ skipNormalize: true,
234
+ // The NSS is opaque to generic URI path processing. In particular, RFC 2141
235
+ // and RFC 8141 do not treat slash-delimited dot segments as equivalent.
236
+ absolutePath: true
234
237
  })
235
238
 
236
239
  const urnuuid = /** @type {SchemeHandler} */ ({
package/lib/utils.js CHANGED
@@ -24,6 +24,12 @@ const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&
24
24
  /** @type {(value: string) => boolean} */
25
25
  const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
26
26
 
27
+ /** @type {(value: string) => boolean} */
28
+ const isRegNameCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=]$/u)
29
+
30
+ /** @type {(value: string) => boolean} */
31
+ const isLiteralRegName = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=]*$/u)
32
+
27
33
  /**
28
34
  * @param {Array<string>} input
29
35
  * @returns {string}
@@ -347,23 +353,27 @@ function removeDotSegments (path) {
347
353
  }
348
354
 
349
355
  /**
350
- * Re-escape RFC 3986 gen-delims that must not appear literally in the host.
351
- * After the URI regex parses, these characters cannot be literal in the host
352
- * field, so any that appear after decoding came from percent-encoding and
353
- * must be restored to prevent authority structure changes.
356
+ * Encodes a host according to the RFC 3986 host grammar. Valid IP literals
357
+ * have already been checked by `normalizeIPv6`, so their address separators
358
+ * remain literal. Every other host is a reg-name and may contain only
359
+ * unreserved characters, sub-delimiters, and valid percent escapes.
354
360
  *
355
361
  * @param {string} host
356
- * @param {boolean} isIP - true for IPv4/IPv6 hosts (skip colon re-escaping)
362
+ * @param {boolean} isIP - true for a validated IPv4/IPv6 host
363
+ * @param {boolean} [allowNonAscii=false] - preserve non-ASCII IRI characters
357
364
  * @returns {string}
358
365
  */
359
- const HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23', ':': '%3A' }
360
- const HOST_DELIM_RE = /[@/?#:]/g
361
- const HOST_DELIM_NO_COLON_RE = /[@/?#]/g
362
-
363
- function reescapeHostDelimiters (host, isIP) {
364
- const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE
365
- re.lastIndex = 0
366
- return host.replace(re, (ch) => HOST_DELIMS[ch])
366
+ const IP_HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23' }
367
+ const IP_HOST_DELIM_RE = /[@/?#]/g
368
+
369
+ function encodeHost (host, isIP, allowNonAscii = false) {
370
+ if (!isIP) {
371
+ if (isLiteralRegName(host)) return host
372
+ return encodeComponent(host, isRegNameCharacter, allowNonAscii)
373
+ }
374
+
375
+ IP_HOST_DELIM_RE.lastIndex = 0
376
+ return host.replace(IP_HOST_DELIM_RE, (ch) => IP_HOST_DELIMS[ch])
367
377
  }
368
378
 
369
379
  /**
@@ -626,9 +636,10 @@ function normalizeQueryFragmentEncoding (input) {
626
636
  *
627
637
  * @param {string} input
628
638
  * @param {(value: string) => boolean} isAllowed
639
+ * @param {boolean} [allowNonAscii=false]
629
640
  * @returns {string}
630
641
  */
631
- function encodeComponent (input, isAllowed) {
642
+ function encodeComponent (input, isAllowed, allowNonAscii = false) {
632
643
  let output = ''
633
644
 
634
645
  for (let i = 0; i < input.length; i++) {
@@ -649,17 +660,19 @@ function encodeComponent (input, isAllowed) {
649
660
  if (code < 0x80) {
650
661
  output += BYTE_HEX[code]
651
662
  } else if (code < 0xD800 || code > 0xDFFF) {
652
- output += percentEncodeNonAscii(code)
663
+ output += allowNonAscii ? ch : percentEncodeNonAscii(code)
653
664
  } else if (code <= 0xDBFF && i + 1 < input.length) {
654
665
  const low = input.charCodeAt(i + 1)
655
666
  if (low >= 0xDC00 && low <= 0xDFFF) {
656
- output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
667
+ output += allowNonAscii
668
+ ? ch + input[i + 1]
669
+ : percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
657
670
  i++
658
671
  } else {
659
- output += percentEncodeNonAscii(0xFFFD)
672
+ output += allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
660
673
  }
661
674
  } else {
662
- output += percentEncodeNonAscii(0xFFFD)
675
+ output += allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
663
676
  }
664
677
  }
665
678
  }
@@ -773,9 +786,10 @@ function recomposeZonedIPv6Host (component) {
773
786
 
774
787
  /**
775
788
  * @param {import('../types/index').URIComponent} component
789
+ * @param {boolean} [allowNonAscii=false] - preserve non-ASCII IRI characters
776
790
  * @returns {string|undefined}
777
791
  */
778
- function recomposeAuthority (component) {
792
+ function recomposeAuthority (component, allowNonAscii = false) {
779
793
  const uriTokens = []
780
794
 
781
795
  if (component.userinfo !== undefined) {
@@ -808,7 +822,7 @@ function recomposeAuthority (component) {
808
822
  if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
809
823
  host = `[${ipV6res.escapedHost}]`
810
824
  } else {
811
- host = reescapeHostDelimiters(host, false)
825
+ host = encodeHost(host, false, allowNonAscii)
812
826
  }
813
827
  }
814
828
  }
@@ -833,7 +847,7 @@ module.exports = {
833
847
  nonSimpleDomain,
834
848
  nonSimpleMailtoDomain,
835
849
  recomposeAuthority,
836
- reescapeHostDelimiters,
850
+ encodeHost,
837
851
  normalizePercentEncoding,
838
852
  normalizePathEncoding,
839
853
  serializePathEncoding,
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.2.1",
4
+ "version": "4.2.2",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "types": "types/index.d.ts",
@@ -61,7 +61,7 @@
61
61
  "ajv": "^8.16.0",
62
62
  "eslint": "^9.17.0",
63
63
  "neostandard": "^0.13.0",
64
- "playwright-test": "^14.1.12",
64
+ "playwright-test": "^15.0.1",
65
65
  "tape": "^5.8.1",
66
66
  "tstyche": "^7.0.0"
67
67
  }
@@ -0,0 +1,137 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ const EDGE_ERROR = 'URI must not contain leading or trailing C0 controls or spaces.'
7
+ const C0_OR_SPACE = Array.from({ length: 0x21 }, (_, code) => String.fromCharCode(code))
8
+
9
+ test('parse rejects leading and trailing C0 controls or spaces', (t) => {
10
+ t.plan(C0_OR_SPACE.length * 2)
11
+
12
+ for (const character of C0_OR_SPACE) {
13
+ t.equal(fastURI.parse(character + 'relative').error, EDGE_ERROR, `leading U+${hex(character)}`)
14
+ t.equal(fastURI.parse('relative' + character).error, EDGE_ERROR, `trailing U+${hex(character)}`)
15
+ }
16
+ })
17
+
18
+ test('normalize preserves references with leading or trailing C0 controls or spaces', (t) => {
19
+ t.plan(C0_OR_SPACE.length * 2)
20
+
21
+ for (const character of C0_OR_SPACE) {
22
+ const leading = character + '//evil.example/path'
23
+ const trailing = '//evil.example/path' + character
24
+ t.equal(fastURI.normalize(leading), leading, `leading U+${hex(character)}`)
25
+ t.equal(fastURI.normalize(trailing), trailing, `trailing U+${hex(character)}`)
26
+ }
27
+ })
28
+
29
+ test('equal rejects references with leading or trailing C0 controls or spaces', (t) => {
30
+ t.plan(C0_OR_SPACE.length * 2)
31
+
32
+ for (const character of C0_OR_SPACE) {
33
+ const leading = character + 'relative'
34
+ const trailing = 'relative' + character
35
+ t.equal(fastURI.equal(leading, leading), false, `leading U+${hex(character)}`)
36
+ t.equal(fastURI.equal(trailing, trailing), false, `trailing U+${hex(character)}`)
37
+ }
38
+ })
39
+
40
+ test('resolve rejects leading and trailing C0 controls or spaces in either input', (t) => {
41
+ t.plan(C0_OR_SPACE.length * 4)
42
+
43
+ for (const character of C0_OR_SPACE) {
44
+ t.throws(
45
+ () => fastURI.resolve(character + 'http://base.example/', 'relative'),
46
+ /leading or trailing C0 controls or spaces/,
47
+ `leading base U+${hex(character)}`
48
+ )
49
+ t.throws(
50
+ () => fastURI.resolve('http://base.example/' + character, 'relative'),
51
+ /leading or trailing C0 controls or spaces/,
52
+ `trailing base U+${hex(character)}`
53
+ )
54
+ t.throws(
55
+ () => fastURI.resolve('http://base.example/', character + '//evil.example/path'),
56
+ /URI (authority introducer must not contain whitespace|must not contain leading or trailing C0 controls or spaces)/,
57
+ `leading relative U+${hex(character)}`
58
+ )
59
+ t.throws(
60
+ () => fastURI.resolve('http://base.example/', '//evil.example/path' + character),
61
+ /leading or trailing C0 controls or spaces/,
62
+ `trailing relative U+${hex(character)}`
63
+ )
64
+ }
65
+ })
66
+
67
+ test('suffix parsing checks the original reference edges', (t) => {
68
+ const options = { reference: 'suffix', scheme: 'http' }
69
+
70
+ t.equal(fastURI.parse('\u0000example', options).error, EDGE_ERROR, 'leading edge before suffix prefixing')
71
+ t.equal(fastURI.parse('example\u0020', options).error, EDGE_ERROR, 'trailing edge before suffix prefixing')
72
+ t.equal(fastURI.normalize('\u0000example', options), '\u0000example', 'normalize preserves a malformed suffix')
73
+ t.equal(fastURI.equal('example\u0020', 'example\u0020', options), false, 'equal rejects a malformed suffix')
74
+ t.end()
75
+ })
76
+
77
+ test('interior and percent-encoded C0 controls or spaces remain data', (t) => {
78
+ t.plan(C0_OR_SPACE.length * 6)
79
+
80
+ for (const character of C0_OR_SPACE) {
81
+ const encoded = percentEncode(character)
82
+ const interior = 'a' + character + 'b'
83
+ const leadingEncoded = encoded + '//evil.example/path'
84
+ const trailingEncoded = '//evil.example/path' + encoded
85
+
86
+ t.equal(fastURI.parse(interior).error, undefined, `interior U+${hex(character)}`)
87
+ t.equal(fastURI.normalize(interior), 'a' + encoded + 'b', `normalize interior U+${hex(character)}`)
88
+ t.equal(fastURI.parse(leadingEncoded).error, undefined, `encoded leading U+${hex(character)}`)
89
+ t.equal(fastURI.normalize(leadingEncoded), leadingEncoded, `normalize encoded leading U+${hex(character)}`)
90
+ t.equal(fastURI.parse(trailingEncoded).error, undefined, `encoded trailing U+${hex(character)}`)
91
+ t.equal(fastURI.normalize(trailingEncoded), trailingEncoded, `normalize encoded trailing U+${hex(character)}`)
92
+ }
93
+ })
94
+
95
+ test('structured component data remains trusted and is encoded', (t) => {
96
+ t.equal(fastURI.serialize({ path: '\u0000' }), '%00', 'serialize encodes a path-edge control')
97
+ t.equal(fastURI.normalize({ path: '\u0020' }).path, '%20', 'object normalization encodes a path-edge space')
98
+ t.end()
99
+ })
100
+
101
+ test('resolve rejects an empty host for HTTP and HTTPS results', (t) => {
102
+ const cases = [
103
+ ['http://base.example/', '///evil.example'],
104
+ ['https://base.example/', '////evil.example'],
105
+ ['http://base.example/', '//'],
106
+ ['http:///evil.example', ''],
107
+ ['http://base.example/', '///evil.example', { scheme: 'null' }]
108
+ ]
109
+
110
+ t.plan(cases.length + 2)
111
+ for (const [base, relative, options] of cases) {
112
+ t.throws(
113
+ () => fastURI.resolve(base, relative, options),
114
+ /HTTP URIs must have a host/,
115
+ `${base} + ${relative}`
116
+ )
117
+ }
118
+
119
+ t.equal(
120
+ fastURI.resolve('http://base.example/', '//other.example/path'),
121
+ 'http://other.example/path',
122
+ 'a non-empty HTTP authority remains valid'
123
+ )
124
+ t.equal(
125
+ fastURI.resolve('uri://base.example/', '///path'),
126
+ 'uri:///path',
127
+ 'generic schemes may retain an empty authority'
128
+ )
129
+ })
130
+
131
+ function hex (character) {
132
+ return character.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')
133
+ }
134
+
135
+ function percentEncode (character) {
136
+ return '%' + character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')
137
+ }
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ const test = require('tape')
4
+ const fastURI = require('..')
5
+
6
+ const C0_OR_SPACE = Array.from({ length: 0x21 }, (_, code) => String.fromCharCode(code))
7
+
8
+ function percentEncode (character) {
9
+ return '%' + character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')
10
+ }
11
+
12
+ test('host normalization percent-encodes interior C0 controls and spaces', (t) => {
13
+ t.plan(C0_OR_SPACE.length * 3)
14
+
15
+ for (const character of C0_OR_SPACE) {
16
+ const input = `custom://a${character}b.example/path`
17
+ const encodedHost = `a${percentEncode(character)}b.example`
18
+ const expected = `custom://${encodedHost}/path`
19
+
20
+ t.equal(fastURI.parse(input).host, encodedHost, `parse U+${percentEncode(character).slice(1)}`)
21
+ t.equal(fastURI.normalize(input), expected, `normalize U+${percentEncode(character).slice(1)}`)
22
+ t.equal(
23
+ fastURI.resolve('custom://base.example/', `//a${character}b.example/path`),
24
+ expected,
25
+ `resolve U+${percentEncode(character).slice(1)}`
26
+ )
27
+ }
28
+ })
29
+
30
+ test('host serialization enforces the RFC 3986 reg-name grammar', (t) => {
31
+ const forbidden = '"<>[\\]^`{|}\x7F:/?#@%'
32
+ const expected = '%22%3C%3E%5B%5C%5D%5E%60%7B%7C%7D%7F%3A%2F%3F%23%40%25'
33
+
34
+ t.equal(
35
+ fastURI.serialize({ scheme: 'custom', host: forbidden, path: '/path' }),
36
+ `custom://${expected}/path`,
37
+ 'forbidden ASCII host characters are percent-encoded'
38
+ )
39
+ t.equal(
40
+ fastURI.serialize({ scheme: 'custom', host: "AZaz09-._~!$&'()*+,;=%2f" }),
41
+ "custom://AZaz09-._~!$&'()*+,;=%2F",
42
+ 'unreserved characters, sub-delimiters, and valid escapes are preserved'
43
+ )
44
+ t.end()
45
+ })
46
+
47
+ test('host encoding preserves URI and IRI behavior', (t) => {
48
+ const input = 'custom://résumé.example/path'
49
+ const encoded = 'custom://r%C3%A9sum%C3%A9.example/path'
50
+
51
+ t.equal(fastURI.normalize(input), encoded, 'URI host uses UTF-8 percent-encoding')
52
+ t.equal(fastURI.normalize(input, { unicodeSupport: true }), input, 'IRI host preserves non-ASCII characters')
53
+ t.equal(fastURI.normalize(encoded), encoded, 'encoded host normalization is idempotent')
54
+ t.equal(
55
+ fastURI.normalize('custom://safe\r\nevil.example/path'),
56
+ 'custom://safe%0D%0Aevil.example/path',
57
+ 'CRLF is not emitted literally'
58
+ )
59
+ t.end()
60
+ })
@@ -3,6 +3,8 @@
3
3
  const test = require('tape')
4
4
  const fastURI = require('..')
5
5
 
6
+ const EDGE_ERROR = 'URI must not contain leading or trailing C0 controls or spaces.'
7
+
6
8
  test('URI parse', (t) => {
7
9
  let components
8
10
 
@@ -92,7 +94,7 @@ test('URI parse', (t) => {
92
94
 
93
95
  // fragment with character tabulation
94
96
  components = fastURI.parse('#\t')
95
- t.equal(components.error, undefined, 'path errors')
97
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing tab is malformed')
96
98
  t.equal(components.scheme, undefined, 'scheme')
97
99
  // t.equal(components.authority, undefined, "authority");
98
100
  t.equal(components.userinfo, undefined, 'userinfo')
@@ -104,7 +106,7 @@ test('URI parse', (t) => {
104
106
 
105
107
  // fragment with line feed
106
108
  components = fastURI.parse('#\n')
107
- t.equal(components.error, undefined, 'path errors')
109
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing line feed is malformed')
108
110
  t.equal(components.scheme, undefined, 'scheme')
109
111
  // t.equal(components.authority, undefined, "authority");
110
112
  t.equal(components.userinfo, undefined, 'userinfo')
@@ -116,7 +118,7 @@ test('URI parse', (t) => {
116
118
 
117
119
  // fragment with line tabulation
118
120
  components = fastURI.parse('#\v')
119
- t.equal(components.error, undefined, 'path errors')
121
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing vertical tab is malformed')
120
122
  t.equal(components.scheme, undefined, 'scheme')
121
123
  // t.equal(components.authority, undefined, "authority");
122
124
  t.equal(components.userinfo, undefined, 'userinfo')
@@ -128,7 +130,7 @@ test('URI parse', (t) => {
128
130
 
129
131
  // fragment with form feed
130
132
  components = fastURI.parse('#\f')
131
- t.equal(components.error, undefined, 'path errors')
133
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing form feed is malformed')
132
134
  t.equal(components.scheme, undefined, 'scheme')
133
135
  // t.equal(components.authority, undefined, "authority");
134
136
  t.equal(components.userinfo, undefined, 'userinfo')
@@ -140,7 +142,7 @@ test('URI parse', (t) => {
140
142
 
141
143
  // fragment with carriage return
142
144
  components = fastURI.parse('#\r')
143
- t.equal(components.error, undefined, 'path errors')
145
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing carriage return is malformed')
144
146
  t.equal(components.scheme, undefined, 'scheme')
145
147
  // t.equal(components.authority, undefined, "authority");
146
148
  t.equal(components.userinfo, undefined, 'userinfo')
@@ -202,8 +202,8 @@ test('resolve applies domain canonicalisation only when the effective scheme opt
202
202
  t.plan(2)
203
203
  t.equal(
204
204
  fastURI.resolve('uri://trusted.example/', `//${host}/`),
205
- `uri://${host}/`,
206
- 'an unsupported scheme preserves the host'
205
+ 'uri://ex%C2%ADample.com/',
206
+ 'a generic URI encodes the host without applying domain canonicalisation'
207
207
  )
208
208
  t.equal(
209
209
  fastURI.resolve('http://trusted.example/', `//${host}/`, { unicodeSupport: true }),
@@ -3,6 +3,8 @@
3
3
  const test = require('tape')
4
4
  const fastURI = require('..')
5
5
 
6
+ const EDGE_ERROR = 'URI must not contain leading or trailing C0 controls or spaces.'
7
+
6
8
  /**
7
9
  * URI.js
8
10
  *
@@ -126,7 +128,7 @@ test('URI Parsing', (t) => {
126
128
 
127
129
  // fragment with character tabulation
128
130
  components = fastURI.parse('#\t')
129
- t.equal(components.error, undefined, 'path errors')
131
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing tab is malformed')
130
132
  t.equal(components.scheme, undefined, 'scheme')
131
133
  t.equal(components.userinfo, undefined, 'userinfo')
132
134
  t.equal(components.host, undefined, 'host')
@@ -137,7 +139,7 @@ test('URI Parsing', (t) => {
137
139
 
138
140
  // fragment with line feed
139
141
  components = fastURI.parse('#\n')
140
- t.equal(components.error, undefined, 'path errors')
142
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing line feed is malformed')
141
143
  t.equal(components.scheme, undefined, 'scheme')
142
144
  t.equal(components.userinfo, undefined, 'userinfo')
143
145
  t.equal(components.host, undefined, 'host')
@@ -148,7 +150,7 @@ test('URI Parsing', (t) => {
148
150
 
149
151
  // fragment with line tabulation
150
152
  components = fastURI.parse('#\v')
151
- t.equal(components.error, undefined, 'path errors')
153
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing vertical tab is malformed')
152
154
  t.equal(components.scheme, undefined, 'scheme')
153
155
  t.equal(components.userinfo, undefined, 'userinfo')
154
156
  t.equal(components.host, undefined, 'host')
@@ -159,7 +161,7 @@ test('URI Parsing', (t) => {
159
161
 
160
162
  // fragment with form feed
161
163
  components = fastURI.parse('#\f')
162
- t.equal(components.error, undefined, 'path errors')
164
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing form feed is malformed')
163
165
  t.equal(components.scheme, undefined, 'scheme')
164
166
  t.equal(components.userinfo, undefined, 'userinfo')
165
167
  t.equal(components.host, undefined, 'host')
@@ -170,7 +172,7 @@ test('URI Parsing', (t) => {
170
172
 
171
173
  // fragment with carriage return
172
174
  components = fastURI.parse('#\r')
173
- t.equal(components.error, undefined, 'path errors')
175
+ t.equal(components.error, EDGE_ERROR, 'fragment with trailing carriage return is malformed')
174
176
  t.equal(components.scheme, undefined, 'scheme')
175
177
  t.equal(components.userinfo, undefined, 'userinfo')
176
178
  t.equal(components.host, undefined, 'host')
@@ -27,3 +27,21 @@ test('URN parsing preserves the complete RFC 2141 NSS', (t) => {
27
27
 
28
28
  t.end()
29
29
  })
30
+
31
+ test('URN serialization preserves NSS dot segments', (t) => {
32
+ const urnWithDotSegments = 'urn:example:a/b/../c'
33
+ const destructiveDotSegments = 'urn:example:../../../etc/passwd'
34
+
35
+ t.equal(fastURI.serialize(fastURI.parse(urnWithDotSegments)), urnWithDotSegments, 'parsed NSS round-trips unchanged')
36
+ t.equal(
37
+ fastURI.serialize({ scheme: 'urn', nid: 'example', nss: '../../../etc/passwd' }),
38
+ destructiveDotSegments,
39
+ 'structured NSS retains leading dot segments'
40
+ )
41
+ t.equal(fastURI.normalize(urnWithDotSegments), urnWithDotSegments, 'normalization retains interior dot segments')
42
+ t.equal(fastURI.normalize(destructiveDotSegments), destructiveDotSegments, 'normalization retains leading dot segments')
43
+ t.equal(fastURI.resolve('', urnWithDotSegments), urnWithDotSegments, 'resolution retains NSS dot segments')
44
+ t.equal(fastURI.equal(urnWithDotSegments, 'urn:example:a/c'), false, 'distinct NSS values do not compare equal')
45
+
46
+ t.end()
47
+ })