Skip to content

Commit 876ce79

Browse files
authored
Merge commit from fork
* fix: preserve reserved path escapes during normalization * refactor: remove unused encoding helper
1 parent dcdf690 commit 876ce79

4 files changed

Lines changed: 165 additions & 31 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ uri.resolve("uri://a/b/c/d?q", "../../g")
6666
"uri://a/g"
6767
```
6868

69+
### Normalize
70+
71+
```js
72+
const uri = require('fast-uri')
73+
uri.normalize('http://example.com/a%2Fb')
74+
// Output
75+
"http://example.com/a%2Fb"
76+
```
77+
78+
Reserved path escapes such as `%2F` and `%2E` are preserved as path data during normalization and comparison.
79+
6980
### Equal
7081

7182
```js

index.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require('./lib/utils')
3+
const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, isIPv4, nonSimpleDomain } = require('./lib/utils')
44
const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
55

66
/**
@@ -107,17 +107,15 @@ function resolveComponent (base, relative, options, skipNormalization) {
107107
*/
108108
function equal (uriA, uriB, options) {
109109
if (typeof uriA === 'string') {
110-
uriA = unescape(uriA)
111-
uriA = serialize(normalizeComponentEncoding(parse(uriA, options), true), { ...options, skipEscape: true })
110+
uriA = serialize(parse(uriA, options), options)
112111
} else if (typeof uriA === 'object') {
113-
uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true })
112+
uriA = serialize(uriA, options)
114113
}
115114

116115
if (typeof uriB === 'string') {
117-
uriB = unescape(uriB)
118-
uriB = serialize(normalizeComponentEncoding(parse(uriB, options), true), { ...options, skipEscape: true })
116+
uriB = serialize(parse(uriB, options), options)
119117
} else if (typeof uriB === 'object') {
120-
uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true })
118+
uriB = serialize(uriB, options)
121119
}
122120

123121
return uriA.toLowerCase() === uriB.toLowerCase()
@@ -156,13 +154,13 @@ function serialize (cmpts, opts) {
156154

157155
if (component.path !== undefined) {
158156
if (!options.skipEscape) {
159-
component.path = escape(component.path)
157+
component.path = escapePreservingEscapes(component.path)
160158

161159
if (component.scheme !== undefined) {
162160
component.path = component.path.split('%3A').join(':')
163161
}
164162
} else {
165-
component.path = unescape(component.path)
163+
component.path = normalizePercentEncoding(component.path)
166164
}
167165
}
168166

@@ -308,7 +306,7 @@ function parse (uri, opts) {
308306
}
309307
}
310308
if (parsed.path) {
311-
parsed.path = escape(unescape(parsed.path))
309+
parsed.path = normalizePathEncoding(parsed.path)
312310
}
313311
if (parsed.fragment) {
314312
parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment))

lib/utils.js

Lines changed: 107 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\d
66
/** @type {(value: string) => boolean} */
77
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)
88

9+
/** @type {(value: string) => boolean} */
10+
const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
11+
12+
/** @type {(value: string) => boolean} */
13+
const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
14+
15+
/** @type {(value: string) => boolean} */
16+
const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
17+
918
/**
1019
* @param {Array<string>} input
1120
* @returns {string}
@@ -264,31 +273,106 @@ function removeDotSegments (path) {
264273
}
265274

266275
/**
267-
* @param {import('../types/index').URIComponent} component
268-
* @param {boolean} esc
269-
* @returns {import('../types/index').URIComponent}
276+
* Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
277+
* Reserved delimiters such as `%2F` and `%2E` stay escaped.
278+
*
279+
* @param {string} input
280+
* @param {boolean} [decodeUnreserved=false]
281+
* @returns {string}
270282
*/
271-
function normalizeComponentEncoding (component, esc) {
272-
const func = esc !== true ? escape : unescape
273-
if (component.scheme !== undefined) {
274-
component.scheme = func(component.scheme)
275-
}
276-
if (component.userinfo !== undefined) {
277-
component.userinfo = func(component.userinfo)
283+
function normalizePercentEncoding (input, decodeUnreserved = false) {
284+
if (input.indexOf('%') === -1) {
285+
return input
278286
}
279-
if (component.host !== undefined) {
280-
component.host = func(component.host)
281-
}
282-
if (component.path !== undefined) {
283-
component.path = func(component.path)
287+
288+
let output = ''
289+
290+
for (let i = 0; i < input.length; i++) {
291+
if (input[i] === '%' && i + 2 < input.length) {
292+
const hex = input.slice(i + 1, i + 3)
293+
if (isHexPair(hex)) {
294+
const normalizedHex = hex.toUpperCase()
295+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
296+
297+
if (decodeUnreserved && isUnreserved(decoded)) {
298+
output += decoded
299+
} else {
300+
output += '%' + normalizedHex
301+
}
302+
303+
i += 2
304+
continue
305+
}
306+
}
307+
308+
output += input[i]
284309
}
285-
if (component.query !== undefined) {
286-
component.query = func(component.query)
310+
311+
return output
312+
}
313+
314+
/**
315+
* Normalizes path data without turning reserved escapes into live path syntax.
316+
* Valid escapes are uppercased, raw unsafe characters are escaped, and only
317+
* unreserved bytes that are not `.` are decoded.
318+
*
319+
* @param {string} input
320+
* @returns {string}
321+
*/
322+
function normalizePathEncoding (input) {
323+
let output = ''
324+
325+
for (let i = 0; i < input.length; i++) {
326+
if (input[i] === '%' && i + 2 < input.length) {
327+
const hex = input.slice(i + 1, i + 3)
328+
if (isHexPair(hex)) {
329+
const normalizedHex = hex.toUpperCase()
330+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
331+
332+
if (decoded !== '.' && isUnreserved(decoded)) {
333+
output += decoded
334+
} else {
335+
output += '%' + normalizedHex
336+
}
337+
338+
i += 2
339+
continue
340+
}
341+
}
342+
343+
if (isPathCharacter(input[i])) {
344+
output += input[i]
345+
} else {
346+
output += escape(input[i])
347+
}
287348
}
288-
if (component.fragment !== undefined) {
289-
component.fragment = func(component.fragment)
349+
350+
return output
351+
}
352+
353+
/**
354+
* Escapes a component while preserving existing valid percent escapes.
355+
*
356+
* @param {string} input
357+
* @returns {string}
358+
*/
359+
function escapePreservingEscapes (input) {
360+
let output = ''
361+
362+
for (let i = 0; i < input.length; i++) {
363+
if (input[i] === '%' && i + 2 < input.length) {
364+
const hex = input.slice(i + 1, i + 3)
365+
if (isHexPair(hex)) {
366+
output += '%' + hex.toUpperCase()
367+
i += 2
368+
continue
369+
}
370+
}
371+
372+
output += escape(input[i])
290373
}
291-
return component
374+
375+
return output
292376
}
293377

294378
/**
@@ -327,7 +411,9 @@ function recomposeAuthority (component) {
327411
module.exports = {
328412
nonSimpleDomain,
329413
recomposeAuthority,
330-
normalizeComponentEncoding,
414+
normalizePercentEncoding,
415+
normalizePathEncoding,
416+
escapePreservingEscapes,
331417
removeDotSegments,
332418
isIPv4,
333419
isUUID,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use strict'
2+
3+
const test = require('tape')
4+
const fastURI = require('..')
5+
6+
test('parse preserves reserved path escapes as data', (t) => {
7+
const components = fastURI.parse('http://example.com/a%2Fb/public/%2e%2e/admin')
8+
9+
t.equal(components.path, '/a%2Fb/public/%2E%2E/admin')
10+
t.end()
11+
})
12+
13+
test('normalize preserves percent-encoded path separators and dot segments', (t) => {
14+
t.equal(
15+
fastURI.normalize('http://example.com/public/%2e%2e/admin'),
16+
'http://example.com/public/%2E%2E/admin'
17+
)
18+
19+
t.equal(
20+
fastURI.normalize('http://example.com/a%2Fb'),
21+
'http://example.com/a%2Fb'
22+
)
23+
24+
t.end()
25+
})
26+
27+
test('equal does not treat reserved path escapes as live path syntax', (t) => {
28+
t.equal(
29+
fastURI.equal('http://example.com/public/%2e%2e/admin', 'http://example.com/admin', {}),
30+
false
31+
)
32+
33+
t.equal(
34+
fastURI.equal('http://example.com/a%2Fb', 'http://example.com/a/b', {}),
35+
false
36+
)
37+
38+
t.end()
39+
})

0 commit comments

Comments
 (0)