Skip to content

Commit 1423a66

Browse files
cursoragentanonrig
andcommitted
url: only percent-decode complete %HH sequences
Leave lone '%' and non-hex sequences such as '%©' intact so serialization matches the previous parser, and use native indexOf/slice/push on the query-string hot path. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yagiz Nizipli <anonrig@users.noreply.github.com>
1 parent 1375cab commit 1423a66

2 files changed

Lines changed: 45 additions & 17 deletions

File tree

lib/internal/url.js

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const {
2121
StringPrototypeCodePointAt,
2222
StringPrototypeIncludes,
2323
StringPrototypeIndexOf,
24-
StringPrototypeReplaceAll,
2524
StringPrototypeSlice,
2625
StringPrototypeStartsWith,
2726
StringPrototypeToWellFormed,
@@ -39,6 +38,7 @@ const { inspect } = require('internal/util/inspect');
3938
const {
4039
encodeStr,
4140
hexTable,
41+
isHexTable,
4242
} = require('internal/querystring');
4343

4444
const {
@@ -1302,18 +1302,36 @@ function unescapeFormComponent(s) {
13021302
}
13031303
}
13041304

1305+
function hasPercentHex(s) {
1306+
const end = s.length - 2;
1307+
for (let i = 0; i < end; i++) {
1308+
if (StringPrototypeCharCodeAt(s, i) === 37 && // '%'
1309+
isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 &&
1310+
isHexTable[StringPrototypeCharCodeAt(s, i + 2)] === 1) {
1311+
return true;
1312+
}
1313+
}
1314+
return false;
1315+
}
1316+
1317+
/* eslint-disable node-core/prefer-primordials */
13051318
function decodeFormComponent(qs, start, end) {
13061319
if (start >= end) {
13071320
return '';
13081321
}
1309-
const s = StringPrototypeSlice(qs, start, end);
1310-
const plus = StringPrototypeIndexOf(s, '+');
1311-
const pct = StringPrototypeIndexOf(s, '%');
1322+
const s = qs.slice(start, end);
1323+
const plus = s.indexOf('+');
1324+
const pct = s.indexOf('%');
13121325
if (plus === -1 && pct === -1) {
13131326
return s;
13141327
}
1315-
const replaced = plus === -1 ? s : StringPrototypeReplaceAll(s, '+', ' ');
1316-
return pct === -1 ? replaced : unescapeFormComponent(replaced);
1328+
const replaced = plus === -1 ? s : s.replaceAll('+', ' ');
1329+
// Only percent-decode when a complete %HH sequence exists. A lone '%' or
1330+
// '%©' must be left intact so later serialization can encode the raw bytes.
1331+
if (pct === -1 || !hasPercentHex(replaced)) {
1332+
return replaced;
1333+
}
1334+
return unescapeFormComponent(replaced);
13171335
}
13181336

13191337
// application/x-www-form-urlencoded parser
@@ -1326,30 +1344,29 @@ function parseParams(qs) {
13261344
}
13271345

13281346
const out = [];
1329-
// indexOf is SIMD-accelerated. When the query has no '+' or '%', every
1330-
// component is a slice of the input and we can skip decodeFormComponent.
1331-
const decode =
1332-
(StringPrototypeIndexOf(qs, '+', i) === -1 &&
1333-
StringPrototypeIndexOf(qs, '%', i) === -1) ?
1334-
StringPrototypeSlice :
1335-
decodeFormComponent;
1347+
// Native indexOf/slice/push outperform primordials on this tight loop.
1348+
const encoded = qs.indexOf('+', i) !== -1 || qs.indexOf('%', i) !== -1;
13361349
while (i < len) {
1337-
let amp = StringPrototypeIndexOf(qs, '&', i);
1350+
let amp = qs.indexOf('&', i);
13381351
if (amp === -1) {
13391352
amp = len;
13401353
}
13411354
if (amp !== i) {
1342-
const eq = StringPrototypeIndexOf(qs, '=', i);
1355+
const eq = qs.indexOf('=', i);
13431356
if (eq === -1 || eq > amp) {
1344-
ArrayPrototypePush(out, decode(qs, i, amp), '');
1357+
out.push(encoded ? decodeFormComponent(qs, i, amp) : qs.slice(i, amp), '');
13451358
} else {
1346-
ArrayPrototypePush(out, decode(qs, i, eq), decode(qs, eq + 1, amp));
1359+
out.push(
1360+
encoded ? decodeFormComponent(qs, i, eq) : qs.slice(i, eq),
1361+
encoded ? decodeFormComponent(qs, eq + 1, amp) : qs.slice(eq + 1, amp),
1362+
);
13471363
}
13481364
}
13491365
i = amp + 1;
13501366
}
13511367
return out;
13521368
}
1369+
/* eslint-enable node-core/prefer-primordials */
13531370

13541371
// Adapted from querystring's implementation.
13551372
// Ref: https://url.spec.whatwg.org/#concept-urlencoded-byte-serializer

test/parallel/test-whatwg-url-searchparams-fast-path.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ const assert = require('assert');
9595
assert.strictEqual(params.toString(), 'a=b+c&d=+');
9696
}
9797

98+
{
99+
// Fake percent-encoding must not be UTF-8-decoded into U+FFFD.
100+
const params = new URLSearchParams('foo=%©ar&baz=%A©uux&xyzzy=%©ud');
101+
assert.deepStrictEqual([...params], [
102+
['foo', '%©ar'],
103+
['baz', '%A©uux'],
104+
['xyzzy', '%©ud'],
105+
]);
106+
assert.strictEqual(params.toString(), 'foo=%25%C2%A9ar&baz=%25A%C2%A9uux&xyzzy=%25%C2%A9ud');
107+
}
108+
98109
{
99110
const url = new URL('https://example.org/?foo=bar');
100111
const params = url.searchParams;

0 commit comments

Comments
 (0)