diff --git a/benchmark/url/url-searchparams-percent-scan.js b/benchmark/url/url-searchparams-percent-scan.js new file mode 100644 index 000000000000..03a6abe2a85f --- /dev/null +++ b/benchmark/url/url-searchparams-percent-scan.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common.js'); +const assert = require('node:assert'); + +const bench = common.createBenchmark(main, { + n: [1e5], + length: [32, 1024], + position: ['start', 'end'], + encoding: ['valid', 'invalid', 'unicode', 'plain'], +}); + +function main({ n, length, position, encoding }) { + const prefix = 'a'.repeat(length); + const [encoded, decoded] = { + valid: ['%20', ' '], + invalid: ['%zz', '%zz'], + unicode: ['%E2%82%AC', '\u20ac'], + plain: ['text', 'text'], + }[encoding]; + const input = position === 'start' ? encoded + prefix : prefix + encoded; + const expected = position === 'start' ? decoded + prefix : prefix + decoded; + const query = `key=${input}`; + for (let index = 0; index < 1000; index++) { + new URLSearchParams(query); + } + + let result; + bench.start(); + for (let index = 0; index < n; index++) { + result = new URLSearchParams(query); + } + bench.end(n); + assert.deepStrictEqual([...result], [['key', expected]]); +} diff --git a/lib/internal/url.js b/lib/internal/url.js index 471df4db439e..86bd6ce5c221 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -1322,11 +1322,11 @@ function unescapeFormComponent(s) { } } -function hasPercentHex(s) { +function hasPercentHex(s, start) { const end = s.length - 2; - for (let i = 0; i < end; i++) { - if (StringPrototypeCharCodeAt(s, i) === 37 && // '%' - isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 && + for (let i = start; i !== -1 && i < end; + i = StringPrototypeIndexOf(s, '%', i + 1)) { + if (isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 && isHexTable[StringPrototypeCharCodeAt(s, i + 2)] === 1) { return true; } @@ -1348,7 +1348,7 @@ function decodeFormComponent(qs, start, end) { // Only percent-decode when a complete %HH sequence exists. A lone '%' or // a '%' followed by a non-hex character must be left intact so later // serialization can encode the raw bytes. - if (pct === -1 || !hasPercentHex(replaced)) { + if (pct === -1 || !hasPercentHex(replaced, pct)) { return replaced; } return unescapeFormComponent(replaced); diff --git a/test/parallel/test-whatwg-url-searchparams-percent-scan.js b/test/parallel/test-whatwg-url-searchparams-percent-scan.js new file mode 100644 index 000000000000..9b8b2d8c0851 --- /dev/null +++ b/test/parallel/test-whatwg-url-searchparams-percent-scan.js @@ -0,0 +1,31 @@ +'use strict'; + +require('../common'); +const assert = require('node:assert'); + +const cases = [ + ['%41', 'A'], + ['%zz', '%zz'], + ['%zz%41', '%zzA'], + ['%', '%'], + ['%A', '%A'], + ['%C3%A9', '\u00e9'], + ['%F0%9F%98%80', '\ud83d\ude00'], + ['%FF', '\ufffd'], + ['%E2%82', '\ufffd'], +]; + +for (const prefix of ['', 'k'.repeat(32), 'k'.repeat(1024), 'k+'.repeat(64)]) { + const decodedPrefix = prefix.replaceAll('+', ' '); + for (const [encoded, decoded] of cases) { + for (const position of ['start', 'end']) { + const input = position === 'start' ? encoded + prefix : prefix + encoded; + const expected = position === 'start' ? + decoded + decodedPrefix : decodedPrefix + decoded; + const query = `${input}=${input}`; + assert.deepStrictEqual([...new URLSearchParams(query)], [[expected, expected]]); + const url = new URL(`https://example.org/?${query}`); + assert.deepStrictEqual([...url.searchParams], [[expected, expected]]); + } + } +}