fast-uri 4.2.1 → 4.2.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.
@@ -0,0 +1,34 @@
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
+ - prepatch
15
+ - preminor
16
+ - premajor
17
+ - prerelease
18
+ preid:
19
+ description: 'Prerelease identifier (e.g. rc), used by the pre* bump types'
20
+ required: false
21
+ type: string
22
+
23
+ permissions: {}
24
+
25
+ jobs:
26
+ release:
27
+ permissions:
28
+ id-token: write # required for npm provenance via OIDC
29
+ contents: write # required to push the release commit and the tag
30
+ uses: fastify/workflows/.github/workflows/reusable-release.yml@b0a2ab2d8259b6bd574b804cd6874bbd75c3577c
31
+ with:
32
+ semver: ${{ inputs.semver }}
33
+ preid: ${{ inputs.preid }}
34
+ dist-tag: ${{ startsWith(inputs.semver, 'pre') && 'next' || 'latest' }}
package/README.md CHANGED
@@ -13,7 +13,11 @@ 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
+
18
+ Schemes are never percent-decoded. RFC 3986 does not allow percent-encoding in a scheme, so an input such as `%6Aavascript:alert(1)` is reported as malformed (`error: 'URI scheme is malformed.'`) instead of being parsed as `javascript:`. `normalize()` leaves it unchanged, `equal()` returns `false`, and `resolve()` and `serialize()` throw.
19
+
20
+ For schemes with domain hosts (`http`, `https`, `ws`, `wss`, or any scheme with the `domainHost` option), a host containing an ASCII character that RFC 3986 does not allow in a reg-name (for example `@`, TAB, LF, CR, space, `"`, `<`, `>`, `^`, `` ` ``, `{`, `|`, `}`) is reported as malformed (`error: 'URI host is malformed.'`). It is never passed through a WHATWG URL parser that could treat `@` as a delimiter or strip whitespace. `normalize()` leaves such input unchanged, `equal()` returns `false`, and `resolve()` throws.
17
21
 
18
22
  * `scheme` (string)
19
23
  Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
@@ -21,6 +25,8 @@ Malformed authorities and out-of-range ports are reported through the parsed com
21
25
  * `reference` (string)
22
26
  If set to `"suffix"`, it indicates that the URI is in the suffix format and the parser will use the option's `scheme` property to determine the URI's scheme.
23
27
 
28
+ A suffix reference is written without its scheme and without the `//` authority marker (RFC 3986 section 4.5). Such a reference is inherently ambiguous: an authority component cannot always be distinguished from a path. When the input carries an authority of the form `host:port` (for example `//javascript:80/x`) or a userinfo containing a colon, the serialized suffix output can begin with text that reparses as a URI scheme (for example `javascript:80/x`). Do not treat suffix output as a trusted absolute URL, and do not render it or use it in a scheme allowlist without first validating it with `parse()`. If you need an unambiguous result, use the default (non-suffix) serialization.
29
+
24
30
  * `tolerant` (boolean, false)
25
31
  If set to `true`, the parser will relax URI resolving rules.
26
32
 
@@ -80,6 +86,8 @@ uri.normalize('http://example.com/a%2Fb')
80
86
 
81
87
  Reserved path escapes such as `%2F` and `%2E` are preserved as path data during normalization and comparison.
82
88
 
89
+ Dot segments in relative references are removed without changing what the reference resolves to: leading `..` segments that cannot be collapsed are kept, a relative path is never turned into a root-relative one, and a first-segment colon exposed by the removal is kept behind a leading `./` so the reference is not reparsed as a scheme (`a/../b` → `b`, `../x` → `../x`, `x/..` → `./`, `a/../a:b` → `./a:b`).
90
+
83
91
  ### Equal
84
92
 
85
93
  ```js
package/index.js CHANGED
@@ -1,21 +1,29 @@
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, removeDotSegmentsRelative, 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
7
7
  const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
8
+ const MALFORMED_HOST_ERROR = 'URI host is malformed.'
9
+ // ASCII characters outside the RFC 3986 reg-name set (unreserved, sub-delims
10
+ // and "%" for pct-encoded): C0 controls, space, DEL, and "#/:<>?@[\]^`{|}.
11
+ const FORBIDDEN_DOMAIN_HOST_CHARACTER = /[\0-\x20"#/:<>?@[\\\]^`{|}\x7F]/u
8
12
 
9
13
  /**
14
+ * RFC 3986 section 3.1 does not allow percent-encoding in a scheme, so the
15
+ * scheme is validated as-is and never decoded. Decoding would let inert input
16
+ * such as `%6Aavascript:` become a live `javascript:` scheme.
17
+ *
10
18
  * @param {string} scheme
11
19
  * @returns {string}
12
20
  */
13
- function decodeValidScheme (scheme) {
14
- const decodedScheme = unescape(String(scheme))
15
- if (!VALID_SCHEME.test(decodedScheme)) {
21
+ function validateScheme (scheme) {
22
+ const stringScheme = String(scheme)
23
+ if (!VALID_SCHEME.test(stringScheme)) {
16
24
  throw new TypeError(MALFORMED_SCHEME_ERROR)
17
25
  }
18
- return decodedScheme
26
+ return stringScheme
19
27
  }
20
28
 
21
29
  /**
@@ -44,6 +52,7 @@ function resolve (baseURI, relativeURI, options) {
44
52
  const {
45
53
  parsed: baseParsed,
46
54
  malformedAuthorityOrPort: baseMalformed,
55
+ malformedReferenceEdge: baseMalformedReferenceEdge,
47
56
  malformedPercentEncoding: baseMalformedPercentEncoding,
48
57
  malformedSchemeSpecific: baseMalformedSchemeSpecific,
49
58
  malformedHost: baseMalformedHost,
@@ -52,6 +61,7 @@ function resolve (baseURI, relativeURI, options) {
52
61
  const {
53
62
  parsed: relativeParsed,
54
63
  malformedAuthorityOrPort: relativeMalformed,
64
+ malformedReferenceEdge: relativeMalformedReferenceEdge,
55
65
  malformedPercentEncoding: relativeMalformedPercentEncoding,
56
66
  malformedSchemeSpecific: relativeMalformedSchemeSpecific,
57
67
  malformedHost: relativeMalformedHost,
@@ -60,6 +70,8 @@ function resolve (baseURI, relativeURI, options) {
60
70
  if (
61
71
  baseMalformed ||
62
72
  relativeMalformed ||
73
+ baseMalformedReferenceEdge ||
74
+ relativeMalformedReferenceEdge ||
63
75
  baseMalformedPercentEncoding ||
64
76
  relativeMalformedPercentEncoding ||
65
77
  baseMalformedSchemeSpecific ||
@@ -74,8 +86,22 @@ function resolve (baseURI, relativeURI, options) {
74
86
  const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true)
75
87
  const resolvedSchemeHandler = getSchemeHandler((options && options.scheme) || resolved.scheme)
76
88
  const resolvedHost = resolved.host
89
+ // Inputs are parsed scheme-neutrally during resolution, so enforce the final
90
+ // HTTP scheme's required host before serializing an invalid HTTP URI.
91
+ if (
92
+ (resolved.scheme === 'http' || resolved.scheme === 'https') &&
93
+ !resolvedHost
94
+ ) {
95
+ throw new Error('HTTP URIs must have a host.')
96
+ }
77
97
  const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
78
98
  (isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
99
+ // Inputs are parsed scheme-neutrally, so delimiters and controls in a domain
100
+ // host are only detected here, once the final scheme is known. They always
101
+ // fail closed, including in percent-encoded hosts.
102
+ if (hasForbiddenDomainHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)) {
103
+ throw new Error(MALFORMED_HOST_ERROR)
104
+ }
79
105
  canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
80
106
  // Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
81
107
  // parser can reject them even though fast-uri preserves them safely as RFC
@@ -86,6 +112,9 @@ function resolve (baseURI, relativeURI, options) {
86
112
  throw new Error(resolved.error)
87
113
  }
88
114
  schemelessOptions.skipEscape = true
115
+ if (resolvedSchemeHandler && resolvedSchemeHandler.absolutePath) {
116
+ schemelessOptions.absolutePath = true
117
+ }
89
118
  return serialize(resolved, schemelessOptions)
90
119
  }
91
120
 
@@ -127,7 +156,10 @@ function resolveComponent (base, relative, options, skipNormalization) {
127
156
  target.userinfo = relative.userinfo
128
157
  copyHost(target, relative)
129
158
  target.port = relative.port
130
- target.path = removeDotSegments(relative.path || '')
159
+ const relativeSchemeHandler = getSchemeHandler(relative.scheme)
160
+ target.path = relativeSchemeHandler && relativeSchemeHandler.absolutePath
161
+ ? relative.path || ''
162
+ : removeDotSegments(relative.path || '')
131
163
  target.query = relative.query
132
164
  } else {
133
165
  if (relative.userinfo !== undefined || relative.host !== undefined || relative.port !== undefined) {
@@ -149,14 +181,17 @@ function resolveComponent (base, relative, options, skipNormalization) {
149
181
  if (relative.path[0] === '/') {
150
182
  target.path = removeDotSegments(relative.path)
151
183
  } else {
152
- if ((base.userinfo !== undefined || base.host !== undefined || base.port !== undefined) && !base.path) {
184
+ const baseHasAuthority = base.userinfo !== undefined || base.host !== undefined || base.port !== undefined
185
+ if (baseHasAuthority && !base.path) {
153
186
  target.path = '/' + relative.path
154
187
  } else if (!base.path) {
155
188
  target.path = relative.path
156
189
  } else {
157
190
  target.path = base.path.slice(0, base.path.lastIndexOf('/') + 1) + relative.path
158
191
  }
159
- target.path = removeDotSegments(target.path)
192
+ target.path = base.scheme === undefined && !baseHasAuthority && target.path[0] !== '/'
193
+ ? removeDotSegmentsRelative(target.path)
194
+ : removeDotSegments(target.path)
160
195
  }
161
196
  target.query = relative.query
162
197
  }
@@ -217,7 +252,7 @@ function serialize (cmpts, opts) {
217
252
  const uriTokens = []
218
253
 
219
254
  if (component.scheme) {
220
- component.scheme = decodeValidScheme(component.scheme)
255
+ component.scheme = validateScheme(component.scheme)
221
256
  }
222
257
 
223
258
  // find scheme handler
@@ -227,7 +262,11 @@ function serialize (cmpts, opts) {
227
262
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
228
263
 
229
264
  const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
230
- const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority
265
+ // A reference with no emitted scheme is path-noscheme: a bare ':' in its
266
+ // first path segment would reparse as a scheme (RFC 3986 3.3/4.2). Enforced
267
+ // under skipEscape too (resolve()), so the reapply below gates on this flag.
268
+ const schemelessReference = (component.scheme === undefined || options.reference === 'suffix') && !hasAuthority
269
+ const pathNoScheme = !options.skipEscape && schemelessReference
231
270
 
232
271
  if (component.path !== undefined) {
233
272
  if (!options.skipEscape) {
@@ -239,11 +278,12 @@ function serialize (cmpts, opts) {
239
278
 
240
279
  if (options.reference !== 'suffix' && component.scheme) {
241
280
  // Scheme handlers may replace the scheme during serialization.
242
- component.scheme = decodeValidScheme(component.scheme)
281
+ component.scheme = validateScheme(component.scheme)
243
282
  uriTokens.push(component.scheme, ':')
244
283
  }
245
284
 
246
- const authority = recomposeAuthority(component)
285
+ const allowNonAsciiHost = options.unicodeSupport === true || Boolean(schemeHandler && schemeHandler.unicodeSupport)
286
+ const authority = recomposeAuthority(component, allowNonAsciiHost)
247
287
  if (authority !== undefined) {
248
288
  if (options.reference !== 'suffix') {
249
289
  uriTokens.push('//')
@@ -259,13 +299,18 @@ function serialize (cmpts, opts) {
259
299
  let s = component.path
260
300
 
261
301
  if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) {
262
- s = removeDotSegments(s)
302
+ // A relative-path reference keeps its unresolvable leading ".." segments
303
+ // and must never become root-relative (RFC 3986 section 5.2.4 applies to
304
+ // merged target paths, not to the reference itself).
305
+ s = schemelessReference && s[0] !== '/'
306
+ ? removeDotSegmentsRelative(s)
307
+ : removeDotSegments(s)
263
308
  }
264
309
 
265
310
  // Dot-segment removal can expose a colon that was not originally in the
266
311
  // first segment (for example, "./a:b"). Reapply path-noscheme encoding so
267
312
  // the serialized relative reference cannot be reparsed as a URI scheme.
268
- if (pathNoScheme) {
313
+ if (schemelessReference) {
269
314
  s = serializePathEncoding(s, true)
270
315
  }
271
316
 
@@ -385,6 +430,11 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
385
430
  parsed.host[0] === '[' &&
386
431
  parsed.host[parsed.host.length - 1] === ']'
387
432
 
433
+ if (hasForbiddenDomainHost(parsed, options, schemeHandler, isIP)) {
434
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
435
+ return true
436
+ }
437
+
388
438
  if (
389
439
  !options.unicodeSupport &&
390
440
  (!schemeHandler || !schemeHandler.unicodeSupport) &&
@@ -395,21 +445,67 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
395
445
  !bracketedIPLiteral &&
396
446
  nonSimpleDomain(parsed.host)
397
447
  ) {
448
+ let url
398
449
  try {
399
- parsed.host = new URL('http://' + parsed.host).hostname
450
+ url = new URL('http://' + parsed.host)
400
451
  } catch (e) {
401
452
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
402
453
  return true
403
454
  }
455
+ // The WHATWG parser must have read the whole input as a host. Anything
456
+ // else means it found a delimiter fast-uri did not, so its hostname would
457
+ // describe a different authority.
458
+ if (!isHostOnlyURL(url)) {
459
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
460
+ return true
461
+ }
462
+ parsed.host = url.hostname
404
463
  }
405
464
 
406
465
  return false
407
466
  }
408
467
 
468
+ /**
469
+ * Whether a domain host contains an ASCII character that RFC 3986 does not
470
+ * allow in a reg-name (unreserved, sub-delims, or pct-encoded). Such a host is
471
+ * rejected instead of being handed to the WHATWG URL parser, which would treat
472
+ * "@" as a userinfo delimiter (silently dropping the data before it) and strip
473
+ * TAB, LF, and CR. Non-ASCII characters remain allowed for IDNs.
474
+ *
475
+ * @param {import('./types/index').URIComponent} parsed
476
+ * @param {import('./types/index').Options} options
477
+ * @param {{ domainHost?: boolean }|undefined} schemeHandler
478
+ * @param {boolean} isIP
479
+ * @returns {boolean}
480
+ */
481
+ function hasForbiddenDomainHost (parsed, options, schemeHandler, isIP) {
482
+ return Boolean(
483
+ parsed.host &&
484
+ isIP === false &&
485
+ !isIPLiteral(parsed.host) &&
486
+ (options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
487
+ FORBIDDEN_DOMAIN_HOST_CHARACTER.test(parsed.host)
488
+ )
489
+ }
490
+
491
+ /**
492
+ * @param {URL} url
493
+ * @returns {boolean}
494
+ */
495
+ function isHostOnlyURL (url) {
496
+ return url.username === '' &&
497
+ url.password === '' &&
498
+ url.port === '' &&
499
+ url.pathname === '/' &&
500
+ url.search === '' &&
501
+ url.hash === '' &&
502
+ url.hostname !== ''
503
+ }
504
+
409
505
  /**
410
506
  * @param {string} uri
411
507
  * @param {import('./types/index').Options} [opts]
412
- * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
508
+ * @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedReferenceEdge: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
413
509
  */
414
510
  function parseWithStatus (uri, opts) {
415
511
  const options = Object.assign({}, opts)
@@ -425,6 +521,12 @@ function parseWithStatus (uri, opts) {
425
521
  }
426
522
 
427
523
  let malformedAuthorityOrPort = false
524
+ // Check the caller's original reference before suffix mode prepends a scheme
525
+ // or authority marker and turns its leading edge into interior data.
526
+ const malformedReferenceEdge = uri.length !== 0 && (
527
+ uri.charCodeAt(0) <= 0x20 ||
528
+ uri.charCodeAt(uri.length - 1) <= 0x20
529
+ )
428
530
  let malformedPercentEncoding = false
429
531
  let malformedSchemeSpecific = false
430
532
  let malformedHost = false
@@ -475,6 +577,10 @@ function parseWithStatus (uri, opts) {
475
577
  }
476
578
  }
477
579
 
580
+ if (malformedReferenceEdge) {
581
+ parsed.error = parsed.error || 'URI must not contain leading or trailing C0 controls or spaces.'
582
+ }
583
+
478
584
  const matches = uri.match(URI_PARSE)
479
585
 
480
586
  if (matches) {
@@ -488,9 +594,8 @@ function parseWithStatus (uri, opts) {
488
594
  parsed.fragment = matches[8]
489
595
 
490
596
  if (parsed.scheme !== undefined) {
491
- const decodedScheme = unescape(parsed.scheme)
492
- if (VALID_SCHEME.test(decodedScheme)) {
493
- parsed.scheme = decodedScheme.toLowerCase()
597
+ if (VALID_SCHEME.test(parsed.scheme)) {
598
+ parsed.scheme = parsed.scheme.toLowerCase()
494
599
  } else {
495
600
  parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
496
601
  malformedScheme = true
@@ -534,7 +639,7 @@ function parseWithStatus (uri, opts) {
534
639
  }
535
640
 
536
641
  if (malformedIPLiteral) {
537
- parsed.error = parsed.error || 'URI host is malformed.'
642
+ parsed.error = parsed.error || MALFORMED_HOST_ERROR
538
643
  malformedAuthorityOrPort = true
539
644
  }
540
645
  } else {
@@ -564,14 +669,24 @@ function parseWithStatus (uri, opts) {
564
669
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
565
670
  }
566
671
 
567
- if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
568
- let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
569
- if (!isIP) {
672
+ if (parsed.host !== undefined && !malformedIPLiteral) {
673
+ let host = parsed.host
674
+ if (uri.indexOf('%') !== -1 && !isIP) {
675
+ host = normalizePercentEncoding(host, true)
570
676
  // Fold reg-name case after decoding unreserved octets. The second
571
677
  // pass only restores uppercase hex in escapes that remain encoded.
572
678
  host = normalizePercentEncoding(host.toLowerCase())
573
679
  }
574
- parsed.host = reescapeHostDelimiters(host, isIP)
680
+ // Resolution parses both inputs with the internal `null` scheme before
681
+ // the effective scheme is known. Preserve raw host characters for that
682
+ // intermediate step so the final scheme can apply domain canonicalization;
683
+ // the resolved authority is encoded when it is serialized.
684
+ if (options.scheme !== 'null') {
685
+ const allowNonAsciiHost = options.unicodeSupport === true || Boolean(schemeHandler && schemeHandler.unicodeSupport)
686
+ parsed.host = encodeHost(host, isIP, allowNonAsciiHost)
687
+ } else {
688
+ parsed.host = host
689
+ }
575
690
  }
576
691
 
577
692
  if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
@@ -596,7 +711,7 @@ function parseWithStatus (uri, opts) {
596
711
  } else {
597
712
  parsed.error = parsed.error || 'URI can not be parsed.'
598
713
  }
599
- return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
714
+ return { parsed, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
600
715
  }
601
716
 
602
717
  /**
@@ -620,13 +735,14 @@ function normalizeString (uri, opts) {
620
735
  /**
621
736
  * @param {string} uri
622
737
  * @param {import('./types/index').Options} [opts]
623
- * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
738
+ * @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedReferenceEdge: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
624
739
  */
625
740
  function normalizeStringWithStatus (uri, opts) {
626
- const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
741
+ const { parsed, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
627
742
  return {
628
- normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
743
+ normalized: malformedAuthorityOrPort || malformedReferenceEdge || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
629
744
  malformedAuthorityOrPort,
745
+ malformedReferenceEdge,
630
746
  malformedPercentEncoding,
631
747
  malformedSchemeSpecific,
632
748
  malformedHost,
@@ -650,8 +766,8 @@ function normalizeComparableURI (uri, opts) {
650
766
  } catch {
651
767
  return undefined
652
768
  }
653
- const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
654
- return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
769
+ const { normalized, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
770
+ return malformedAuthorityOrPort || malformedReferenceEdge || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
655
771
  }
656
772
 
657
773
  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,78 @@ 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
+ * Removes dot segments from the path of a relative reference (a path that does
357
+ * not start with "/") without changing what it resolves to against any base.
358
+ * Unlike `removeDotSegments`, leading ".." segments that cannot be collapsed
359
+ * are kept, the result never becomes root-relative, and a result that would be
360
+ * empty or start with an empty segment is prefixed with "." (an empty path
361
+ * means "same document" and a leading "//" would introduce an authority).
362
+ *
363
+ * @param {string} path
364
+ * @returns {string}
365
+ */
366
+ function removeDotSegmentsRelative (path) {
367
+ if (path === '' || path[0] === '/') {
368
+ return removeDotSegments(path)
369
+ }
370
+
371
+ const segments = path.split('/')
372
+ const last = segments.length - 1
373
+ /** @type {string[]} */
374
+ const output = []
375
+ let trailingDirectory = false
376
+
377
+ for (let i = 0; i <= last; i++) {
378
+ const segment = segments[i]
379
+ if (segment === '.' || segment === '..') {
380
+ if (segment === '..') {
381
+ if (output.length !== 0 && output[output.length - 1] !== '..') {
382
+ output.pop()
383
+ } else {
384
+ output.push('..')
385
+ }
386
+ }
387
+ trailingDirectory = i === last
388
+ } else {
389
+ output.push(segment)
390
+ }
391
+ }
392
+
393
+ if (trailingDirectory && output[output.length - 1] !== '..') {
394
+ output.push('')
395
+ }
396
+ if (output.length === 0 || output[0] === '' || output[0].includes(':')) {
397
+ // An empty path, a leading empty segment ("//"), or a first segment
398
+ // containing ":" (which would be mistaken for a scheme, RFC 3986 section
399
+ // 4.2) must stay behind a "." segment so the reference keeps its meaning.
400
+ output.unshift('.')
401
+ }
402
+
403
+ return output.join('/')
404
+ }
405
+
406
+ /**
407
+ * Encodes a host according to the RFC 3986 host grammar. Valid IP literals
408
+ * have already been checked by `normalizeIPv6`, so their address separators
409
+ * remain literal. Every other host is a reg-name and may contain only
410
+ * unreserved characters, sub-delimiters, and valid percent escapes.
354
411
  *
355
412
  * @param {string} host
356
- * @param {boolean} isIP - true for IPv4/IPv6 hosts (skip colon re-escaping)
413
+ * @param {boolean} isIP - true for a validated IPv4/IPv6 host
414
+ * @param {boolean} [allowNonAscii=false] - preserve non-ASCII IRI characters
357
415
  * @returns {string}
358
416
  */
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])
417
+ const IP_HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23' }
418
+ const IP_HOST_DELIM_RE = /[@/?#]/g
419
+
420
+ function encodeHost (host, isIP, allowNonAscii = false) {
421
+ if (!isIP) {
422
+ if (isLiteralRegName(host)) return host
423
+ return encodeComponent(host, isRegNameCharacter, allowNonAscii)
424
+ }
425
+
426
+ IP_HOST_DELIM_RE.lastIndex = 0
427
+ return host.replace(IP_HOST_DELIM_RE, (ch) => IP_HOST_DELIMS[ch])
367
428
  }
368
429
 
369
430
  /**
@@ -626,9 +687,10 @@ function normalizeQueryFragmentEncoding (input) {
626
687
  *
627
688
  * @param {string} input
628
689
  * @param {(value: string) => boolean} isAllowed
690
+ * @param {boolean} [allowNonAscii=false]
629
691
  * @returns {string}
630
692
  */
631
- function encodeComponent (input, isAllowed) {
693
+ function encodeComponent (input, isAllowed, allowNonAscii = false) {
632
694
  let output = ''
633
695
 
634
696
  for (let i = 0; i < input.length; i++) {
@@ -649,17 +711,19 @@ function encodeComponent (input, isAllowed) {
649
711
  if (code < 0x80) {
650
712
  output += BYTE_HEX[code]
651
713
  } else if (code < 0xD800 || code > 0xDFFF) {
652
- output += percentEncodeNonAscii(code)
714
+ output += allowNonAscii ? ch : percentEncodeNonAscii(code)
653
715
  } else if (code <= 0xDBFF && i + 1 < input.length) {
654
716
  const low = input.charCodeAt(i + 1)
655
717
  if (low >= 0xDC00 && low <= 0xDFFF) {
656
- output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
718
+ output += allowNonAscii
719
+ ? ch + input[i + 1]
720
+ : percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
657
721
  i++
658
722
  } else {
659
- output += percentEncodeNonAscii(0xFFFD)
723
+ output += allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
660
724
  }
661
725
  } else {
662
- output += percentEncodeNonAscii(0xFFFD)
726
+ output += allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
663
727
  }
664
728
  }
665
729
  }
@@ -773,9 +837,10 @@ function recomposeZonedIPv6Host (component) {
773
837
 
774
838
  /**
775
839
  * @param {import('../types/index').URIComponent} component
840
+ * @param {boolean} [allowNonAscii=false] - preserve non-ASCII IRI characters
776
841
  * @returns {string|undefined}
777
842
  */
778
- function recomposeAuthority (component) {
843
+ function recomposeAuthority (component, allowNonAscii = false) {
779
844
  const uriTokens = []
780
845
 
781
846
  if (component.userinfo !== undefined) {
@@ -808,7 +873,7 @@ function recomposeAuthority (component) {
808
873
  if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
809
874
  host = `[${ipV6res.escapedHost}]`
810
875
  } else {
811
- host = reescapeHostDelimiters(host, false)
876
+ host = encodeHost(host, false, allowNonAscii)
812
877
  }
813
878
  }
814
879
  }
@@ -833,7 +898,7 @@ module.exports = {
833
898
  nonSimpleDomain,
834
899
  nonSimpleMailtoDomain,
835
900
  recomposeAuthority,
836
- reescapeHostDelimiters,
901
+ encodeHost,
837
902
  normalizePercentEncoding,
838
903
  normalizePathEncoding,
839
904
  serializePathEncoding,
@@ -843,6 +908,7 @@ module.exports = {
843
908
  encodeFragment,
844
909
  escapePreservingEscapes,
845
910
  removeDotSegments,
911
+ removeDotSegmentsRelative,
846
912
  isIPv4,
847
913
  isUUID,
848
914
  normalizeIPv6,
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.3",
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
  }