Skip to content
File

Blob: src/node/internal/internal_querystring.ts

typescript507 lines
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4// Copyright Joyent and Node contributors. All rights reserved. MIT license.
5 
6import { Buffer } from 'node-internal:internal_buffer';
7import { ERR_INVALID_URI } from 'node-internal:internal_errors';
8 
9type EncodeFunction = (value: string) => string;
10type DecodeFunction = (value: string) => string;
11 
12export const hexTable = Array.from({ length: 256 }, (_, i) => {
13 return '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase();
14});
15 
16// prettier-ignore
17const isHexTable = new Int8Array([
18 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
19 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
20 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 32 - 47
21 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
22 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 64 - 79
23 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 80 - 95
24 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 96 - 111
25 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 112 - 127
26 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 128 ...
27 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
28 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
29 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
30 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
31 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
32 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
33 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ... 256
34]);
35 
36/* eslint-disable */
37export function encodeStr(
38 str: string,
39 noEscapeTable: Int8Array,
40 hexTable: string[]
41): string {
42 const len = str.length;
43 if (len === 0) return '';
44 
45 let out = '';
46 let lastPos = 0;
47 let i = 0;
48 
49 outer: for (; i < len; i++) {
50 let c = str.charCodeAt(i);
51 
52 // ASCII
53 while (c < 0x80) {
54 if (noEscapeTable[c] !== 1) {
55 if (lastPos < i) out += str.slice(lastPos, i);
56 lastPos = i + 1;
57 out += hexTable[c]!;
58 }
59 
60 if (++i === len) break outer;
61 
62 c = str.charCodeAt(i);
63 }
64 
65 if (lastPos < i) out += str.slice(lastPos, i);
66 
67 // Multi-byte characters ...
68 if (c < 0x800) {
69 lastPos = i + 1;
70 out += hexTable[0xc0 | (c >> 6)]! + hexTable[0x80 | (c & 0x3f)]!;
71 continue;
72 }
73 if (c < 0xd800 || c >= 0xe000) {
74 lastPos = i + 1;
75 out +=
76 hexTable[0xe0 | (c >> 12)]! +
77 hexTable[0x80 | ((c >> 6) & 0x3f)]! +
78 hexTable[0x80 | (c & 0x3f)];
79 continue;
80 }
81 // Surrogate pair
82 ++i;
83 
84 // This branch should never happen because all URLSearchParams entries
85 // should already be converted to USVString. But, included for
86 // completion's sake anyway.
87 if (i >= len) throw new ERR_INVALID_URI();
88 
89 const c2 = str.charCodeAt(i) & 0x3ff;
90 
91 lastPos = i + 1;
92 c = 0x10000 + (((c & 0x3ff) << 10) | c2);
93 out +=
94 hexTable[0xf0 | (c >> 18)]! +
95 hexTable[0x80 | ((c >> 12) & 0x3f)]! +
96 hexTable[0x80 | ((c >> 6) & 0x3f)] +
97 hexTable[0x80 | (c & 0x3f)];
98 }
99 if (lastPos === 0) return str;
100 if (lastPos < len) return out + str.slice(lastPos);
101 return out;
102}
103/* eslint-enable */
104 
105/* eslint-disable @typescript-eslint/no-unnecessary-type-conversion */
106 
107// prettier-ignore
108const unhexTable = new Int8Array([
109 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 0 - 15
110 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 16 - 31
111 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 32 - 47
112 +0, +1, +2, +3, +4, +5, +6, +7, +8, +9, -1, -1, -1, -1, -1, -1, // 48 - 63
113 -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 64 - 79
114 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 80 - 95
115 -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 96 - 111
116 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 112 - 127
117 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 128 ...
118 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
119 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
120 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
121 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
122 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
123 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
124 -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // ... 255
125]);
126 
127export function unescapeBuffer(
128 s: string,
129 decodeSpaces: boolean
130): Buffer | Uint8Array {
131 const out = Buffer.allocUnsafe(s.length);
132 let index: number = 0;
133 let outIndex: number = 0;
134 let currentChar: number;
135 let nextChar: number;
136 let hexHigh: number;
137 let hexLow: number;
138 const maxLength = s.length - 2;
139 // Flag to know if some hex chars have been decoded
140 let hasHex = false;
141 while (index < s.length) {
142 currentChar = s.charCodeAt(index);
143 if (currentChar === 43 /* '+' */ && decodeSpaces) {
144 out[outIndex++] = 32; // ' '
145 index++;
146 continue;
147 }
148 if (currentChar === 37 /* '%' */ && index < maxLength) {
149 currentChar = s.charCodeAt(++index);
150 hexHigh = unhexTable[currentChar] as number;
151 if (!(hexHigh >= 0)) {
152 out[outIndex++] = 37; // '%'
153 continue;
154 } else {
155 nextChar = s.charCodeAt(++index);
156 hexLow = unhexTable[nextChar] as number;
157 if (!(hexLow >= 0)) {
158 out[outIndex++] = 37; // '%'
159 index--;
160 } else {
161 hasHex = true;
162 currentChar = hexHigh * 16 + hexLow;
163 }
164 }
165 }
166 out[outIndex++] = currentChar;
167 index++;
168 }
169 return hasHex ? out.slice(0, outIndex) : out;
170}
171 
172/**
173 * @param {string} s
174 * @param {boolean} decodeSpaces
175 * @returns {string}
176 */
177export function unescape(s: string, decodeSpaces: boolean): string {
178 try {
179 return decodeURIComponent(s);
180 } catch {
181 return unescapeBuffer(s, decodeSpaces).toString();
182 }
183}
184 
185// These characters do not need escaping when generating query strings:
186// ! - . _ ~
187// ' ( ) *
188// digits
189// alpha (uppercase)
190// alpha (lowercase)
191 
192// prettier-ignore
193const noEscape = new Int8Array([
194 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
195 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
196 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, // 32 - 47
197 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
198 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
199 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, // 80 - 95
200 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
201 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, // 112 - 127
202]);
203 
204/**
205 * QueryString.escape() replaces encodeURIComponent()
206 * @see https://www.ecma-international.org/ecma-262/5.1/#sec-15.1.3.4
207 * @param {any} input
208 * @returns {string}
209 */
210export function escape(input: unknown): string {
211 let str: string;
212 if (typeof input !== 'string') {
213 // eslint-disable-next-line @typescript-eslint/no-base-to-string
214 if (typeof input === 'object') str = String(input);
215 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands,@typescript-eslint/no-base-to-string
216 else str = input + '';
217 } else {
218 str = input;
219 }
220 
221 return encodeStr(str, noEscape, hexTable);
222}
223 
224/**
225 * @param {string | number | bigint | boolean | symbol | undefined | null} v
226 * @returns {string}
227 */
228function stringifyPrimitive(v: unknown): string {
229 if (typeof v === 'string') return v;
230 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
231 if (typeof v === 'number' && Number.isFinite(v)) return '' + v;
232 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
233 if (typeof v === 'bigint') return '' + v;
234 if (typeof v === 'boolean') return v ? 'true' : 'false';
235 return '';
236}
237 
238function encodeStringified(v: unknown, encode: EncodeFunction): string {
239 if (typeof v === 'string') return v.length ? encode(v) : '';
240 if (typeof v === 'number' && Number.isFinite(v)) {
241 // Values >= 1e21 automatically switch to scientific notation which requires
242 // escaping due to the inclusion of a '+' in the output
243 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
244 return Math.abs(v) < 1e21 ? '' + v : encode('' + v);
245 }
246 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
247 if (typeof v === 'bigint') return '' + v;
248 if (typeof v === 'boolean') return v ? 'true' : 'false';
249 return '';
250}
251 
252function encodeStringifiedCustom(v: unknown, encode: EncodeFunction): string {
253 return encode(stringifyPrimitive(v));
254}
255 
256export function stringify(
257 obj: unknown,
258 sep?: string,
259 eq?: string,
260 options?: { encodeURIComponent?: EncodeFunction }
261): string {
262 sep ||= '&';
263 eq ||= '=';
264 
265 let encode = escape as EncodeFunction;
266 if (options && typeof options.encodeURIComponent === 'function') {
267 encode = options.encodeURIComponent;
268 }
269 const convert =
270 encode === escape ? encodeStringified : encodeStringifiedCustom;
271 
272 if (obj !== null && typeof obj === 'object') {
273 const keys = Object.keys(obj);
274 const len = keys.length;
275 let fields = '';
276 for (let i = 0; i < len; ++i) {
277 const k = keys[i] as keyof typeof obj;
278 const v = obj[k] as unknown;
279 let ks = convert(k, encode);
280 ks += eq;
281 
282 if (Array.isArray(v)) {
283 const vlen = v.length;
284 if (vlen === 0) continue;
285 if (fields) fields += sep;
286 for (let j = 0; j < vlen; ++j) {
287 if (j) fields += sep;
288 fields += ks;
289 fields += convert(v[j], encode);
290 }
291 } else {
292 if (fields) fields += sep;
293 fields += ks;
294 fields += convert(v, encode);
295 }
296 }
297 return fields;
298 }
299 return '';
300}
301 
302/**
303 * @param {string} str
304 * @returns {number[]}
305 */
306function charCodes(str: string): number[] {
307 if (str.length === 0) return [];
308 if (str.length === 1) return [str.charCodeAt(0)];
309 return Array.from({ length: str.length }, (_, i) => str.charCodeAt(i));
310}
311const defSepCodes = [38]; // &
312const defEqCodes = [61]; // =
313 
314function addKeyVal(
315 obj: Record<string, unknown>,
316 key: string,
317 value: string,
318 keyEncoded: boolean,
319 valEncoded: boolean,
320 decode: DecodeFunction
321): void {
322 if (key.length > 0 && keyEncoded) key = decodeStr(key, decode);
323 if (value.length > 0 && valEncoded) value = decodeStr(value, decode);
324 
325 if (obj[key] === undefined) {
326 obj[key] = value;
327 } else {
328 const curValue = obj[key];
329 // A simple Array-specific property check is enough here to
330 // distinguish from a string value and is faster and still safe
331 // since we are generating all of the values being assigned.
332 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
333 // @ts-expect-error TS18046
334 if (curValue.pop) {
335 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
336 // @ts-expect-error TS18046
337 curValue[curValue.length] = value; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
338 } else {
339 obj[key] = [curValue, value];
340 }
341 }
342}
343 
344export function parse(
345 qs: string,
346 sep?: string,
347 eq?: string,
348 options?: {
349 maxKeys?: number;
350 decodeURIComponent?: DecodeFunction;
351 }
352): Record<string, unknown> {
353 const obj = { __proto__: null };
354 
355 if (typeof qs !== 'string' || qs.length === 0) {
356 return obj;
357 }
358 
359 const sepCodes = !sep ? defSepCodes : charCodes(String(sep));
360 const eqCodes = !eq ? defEqCodes : charCodes(String(eq));
361 const sepLen = sepCodes.length;
362 const eqLen = eqCodes.length;
363 
364 let pairs = 1000;
365 if (options && typeof options.maxKeys === 'number') {
366 // -1 is used in place of a value like Infinity for meaning
367 // "unlimited pairs" because of additional checks V8 (at least as of v5.4)
368 // has to do when using variables that contain values like Infinity. Since
369 // `pairs` is always decremented and checked explicitly for 0, -1 works
370 // effectively the same as Infinity, while providing a significant
371 // performance boost.
372 pairs = options.maxKeys > 0 ? options.maxKeys : -1;
373 }
374 
375 let decode = unescape as DecodeFunction;
376 if (options && typeof options.decodeURIComponent === 'function') {
377 decode = options.decodeURIComponent;
378 }
379 const customDecode = decode !== unescape;
380 
381 let lastPos = 0;
382 let sepIdx = 0;
383 let eqIdx = 0;
384 let key = '';
385 let value = '';
386 let keyEncoded = customDecode;
387 let valEncoded = customDecode;
388 const plusChar = customDecode ? '%20' : ' ';
389 let encodeCheck = 0;
390 for (let i = 0; i < qs.length; ++i) {
391 const code = qs.charCodeAt(i);
392 
393 // Try matching key/value pair separator (e.g. '&')
394 if (code === sepCodes[sepIdx]) {
395 if (++sepIdx === sepLen) {
396 // Key/value pair separator match!
397 const end = i - sepIdx + 1;
398 if (eqIdx < eqLen) {
399 // We didn't find the (entire) key/value separator
400 if (lastPos < end) {
401 // Treat the substring as part of the key instead of the value
402 key += qs.slice(lastPos, end);
403 } else if (key.length === 0) {
404 // We saw an empty substring between separators
405 if (--pairs === 0) return obj;
406 lastPos = i + 1;
407 sepIdx = eqIdx = 0;
408 continue;
409 }
410 } else if (lastPos < end) {
411 value += qs.slice(lastPos, end);
412 }
413 
414 addKeyVal(obj, key, value, keyEncoded, valEncoded, decode);
415 
416 if (--pairs === 0) return obj;
417 keyEncoded = valEncoded = customDecode;
418 key = value = '';
419 encodeCheck = 0;
420 lastPos = i + 1;
421 sepIdx = eqIdx = 0;
422 }
423 } else {
424 sepIdx = 0;
425 // Try matching key/value separator (e.g. '=') if we haven't already
426 if (eqIdx < eqLen) {
427 if (code === eqCodes[eqIdx]) {
428 if (++eqIdx === eqLen) {
429 // Key/value separator match!
430 const end = i - eqIdx + 1;
431 if (lastPos < end) key += qs.slice(lastPos, end);
432 encodeCheck = 0;
433 lastPos = i + 1;
434 }
435 continue;
436 } else {
437 eqIdx = 0;
438 if (!keyEncoded) {
439 // Try to match an (valid) encoded byte once to minimize unnecessary
440 // calls to string decoding functions
441 if (code === 37 /* % */) {
442 encodeCheck = 1;
443 continue;
444 } else if (encodeCheck > 0) {
445 if (isHexTable[code] === 1) {
446 if (++encodeCheck === 3) keyEncoded = true;
447 continue;
448 } else {
449 encodeCheck = 0;
450 }
451 }
452 }
453 }
454 if (code === 43 /* + */) {
455 if (lastPos < i) key += qs.slice(lastPos, i);
456 key += plusChar;
457 lastPos = i + 1;
458 continue;
459 }
460 }
461 if (code === 43 /* + */) {
462 if (lastPos < i) value += qs.slice(lastPos, i);
463 value += plusChar;
464 lastPos = i + 1;
465 } else if (!valEncoded) {
466 // Try to match an (valid) encoded byte (once) to minimize unnecessary
467 // calls to string decoding functions
468 if (code === 37 /* % */) {
469 encodeCheck = 1;
470 } else if (encodeCheck > 0) {
471 if (isHexTable[code] === 1) {
472 if (++encodeCheck === 3) valEncoded = true;
473 } else {
474 encodeCheck = 0;
475 }
476 }
477 }
478 }
479 }
480 
481 // Deal with any leftover key or value data
482 if (lastPos < qs.length) {
483 if (eqIdx < eqLen) key += qs.slice(lastPos);
484 else if (sepIdx < sepLen) value += qs.slice(lastPos);
485 } else if (eqIdx === 0 && key.length === 0) {
486 // We ended on an empty substring
487 return obj;
488 }
489 
490 addKeyVal(obj, key, value, keyEncoded, valEncoded, decode);
491 
492 return obj;
493}
494 
495/**
496 * V8 does not optimize functions with try-catch blocks, so we isolate them here
497 * to minimize the damage (Note: no longer true as of V8 5.4 -- but still will
498 * not be inlined).
499 */
500function decodeStr(s: string, decoder: DecodeFunction): string {
501 try {
502 return decoder(s);
503 } catch {
504 return unescape(s, true);
505 }
506}