Skip to main content

nodejs/stdlib/
querystring.rs

1//! Node `querystring` module: `parse`/`stringify` (with the `escape`/`unescape`
2//! aliases `encode`/`decode`). Values are percent-decoded/encoded with `+`
3//! standing for a space, the legacy `application/x-www-form-urlencoded` rules
4//! Node's `querystring` uses (distinct from the `qs` package express also ships).
5
6use crate::host::{with_host, JsObj};
7use fusevm::Value;
8use indexmap::IndexMap;
9
10pub const METHODS: &[&str] = &[
11    "parse",
12    "stringify",
13    "escape",
14    "unescape",
15    "encode",
16    "decode",
17    "unescapeBuffer",
18];
19
20pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
21    Some(match method {
22        "parse" | "decode" => Ok(parse(&super::arg_str(args, 0), args)),
23        "stringify" | "encode" => Ok(stringify(args)),
24        "escape" => {
25            // arg_str borrows the host; compute it BEFORE the new_str with_host.
26            let s = super::arg_str(args, 0);
27            Ok(with_host(|h| h.new_str(escape(&s))))
28        }
29        "unescape" => {
30            let s = super::arg_str(args, 0);
31            Ok(with_host(|h| h.new_str(unescape(&s))))
32        }
33        // `querystring.unescapeBuffer(str[, decodeSpaces])` → a Buffer of the raw
34        // decoded bytes. `+` is decoded to a space only when `decodeSpaces` is true
35        // (Node's default is false).
36        "unescapeBuffer" => {
37            let s = super::arg_str(args, 0);
38            let decode_spaces = matches!(args.get(1), Some(Value::Bool(true)));
39            Ok(super::buffer::from_bytes(&unescape_buffer(
40                &s,
41                decode_spaces,
42            )))
43        }
44        _ => return None,
45    })
46}
47
48/// `querystring.parse(str[, sep[, eq]])` → an object of decoded key/value pairs.
49/// A repeated key collects its values into an array, matching Node.
50///
51/// An explicitly-passed `undefined` separator means "use the default", not the
52/// STRING `"undefined"` — `body-parser` calls
53/// `parse(body, undefined, undefined, { maxKeys })`, and coercing those to text
54/// made the whole body one key.
55fn parse(s: &str, args: &[Value]) -> Value {
56    let sep = args
57        .get(1)
58        .filter(|v| !matches!(v, Value::Undef))
59        .map(|_| super::arg_str(args, 1))
60        .filter(|s| !s.is_empty())
61        .unwrap_or_else(|| "&".into());
62    let eq = args
63        .get(2)
64        .filter(|v| !matches!(v, Value::Undef))
65        .map(|_| super::arg_str(args, 2))
66        .filter(|s| !s.is_empty())
67        .unwrap_or_else(|| "=".into());
68    // `maxKeys` (options.maxKeys, default 1000; 0 means unlimited) caps how many
69    // DISTINCT keys are kept. It was ignored entirely, so a hostile query string
70    // could allocate without bound — which is the reason node has the cap.
71    let max_keys = args
72        .get(3)
73        .filter(|v| !matches!(v, Value::Undef))
74        .and_then(|o| crate::builtins::get_property(o, "maxKeys").ok())
75        .filter(|v| !matches!(v, Value::Undef))
76        .map(|v| with_host(|h| h.to_number(&v)))
77        .filter(|n| n.is_finite() && *n >= 0.0)
78        .map(|n| n as usize)
79        .unwrap_or(1000);
80    let mut map: IndexMap<String, Value> = IndexMap::new();
81    if !s.is_empty() {
82        for pair in s.split(&sep) {
83            if pair.is_empty() {
84                continue;
85            }
86            if max_keys != 0 && map.len() >= max_keys {
87                break;
88            }
89            let (k, v) = match pair.split_once(&eq) {
90                Some((k, v)) => (unescape_form(k), unescape_form(v)),
91                None => (unescape_form(pair), String::new()),
92            };
93            let val = with_host(|h| h.new_str(v));
94            // A repeated key promotes to (and then extends) an array.
95            match map.get(&k).cloned() {
96                Some(existing) => {
97                    let is_arr = with_host(|h| matches!(h.get(&existing), Some(JsObj::Array(_))));
98                    if is_arr {
99                        with_host(|h| {
100                            if let Some(JsObj::Array(items)) = h.get_mut(&existing) {
101                                items.push(val);
102                            }
103                        });
104                    } else {
105                        let arr = with_host(|h| h.new_array(vec![existing, val]));
106                        map.insert(k, arr);
107                    }
108                }
109                None => {
110                    map.insert(k, val);
111                }
112            }
113        }
114    }
115    // The result has a NULL prototype, so a `__proto__` or `constructor` key in
116    // the query string is an ordinary own property rather than a reference to
117    // something inherited. It was inheriting `Object.prototype`.
118    with_host(|h| {
119        let obj = h.new_object(map);
120        let null = h.null();
121        h.set_proto(&obj, null);
122        obj
123    })
124}
125
126/// The serialized form of one `stringify` value.
127///
128/// Only a string, number, bigint or boolean has one; `null`, `undefined`, an
129/// object and a symbol all serialize to the EMPTY string, which is why
130/// `stringify({ a: null })` is `a=`. This used to run everything through
131/// `String(v)`, so a null came out as the text "null" and an object as
132/// "[object Object]" — both of which parse back as data.
133fn stringify_value(v: &Value) -> String {
134    with_host(|h| match v {
135        Value::Bool(_) | Value::Int(_) | Value::Float(_) => h.str_of(v),
136        Value::Str(_) => h.str_of(v),
137        Value::Obj(_) => match h.get(v) {
138            Some(JsObj::Str(_)) | Some(JsObj::BigInt(_)) => h.str_of(v),
139            _ => String::new(),
140        },
141        _ => String::new(),
142    })
143}
144
145/// `querystring.stringify(obj[, sep[, eq]])`.
146fn stringify(args: &[Value]) -> Value {
147    let obj = args.first().cloned().unwrap_or(Value::Undef);
148    let sep = args
149        .get(1)
150        .filter(|v| !matches!(v, Value::Undef))
151        .map(|_| super::arg_str(args, 1))
152        .filter(|s| !s.is_empty())
153        .unwrap_or_else(|| "&".into());
154    let eq = args
155        .get(2)
156        .filter(|v| !matches!(v, Value::Undef))
157        .map(|_| super::arg_str(args, 2))
158        .filter(|s| !s.is_empty())
159        .unwrap_or_else(|| "=".into());
160    let entries = with_host(|h| match h.get(&obj) {
161        Some(JsObj::Object(p)) => p
162            .iter()
163            .filter(|(k, _)| !k.starts_with("@@"))
164            .map(|(k, v)| (k.clone(), v.clone()))
165            .collect::<Vec<_>>(),
166        _ => Vec::new(),
167    });
168    let mut parts: Vec<String> = Vec::new();
169    for (k, v) in entries {
170        let ek = escape(&k);
171        // An array value emits one `key=elem` pair per element.
172        let elems = with_host(|h| match h.get(&v) {
173            Some(JsObj::Array(items)) => Some(items.clone()),
174            _ => None,
175        });
176        match elems {
177            Some(list) => {
178                // Each element goes through the same primitive-only rule as a
179                // scalar value, so a null or object element is an empty string.
180                for e in list {
181                    parts.push(format!("{ek}{eq}{}", escape(&stringify_value(&e))));
182                }
183            }
184            None => {
185                parts.push(format!("{ek}{eq}{}", escape(&stringify_value(&v))));
186            }
187        }
188    }
189    with_host(|h| h.new_str(parts.join(&sep)))
190}
191
192/// `querystring.unescapeBuffer` core — decode `%XX` to raw bytes (and `+` to a
193/// space when `decode_spaces`), leaving malformed escapes literal.
194fn unescape_buffer(s: &str, decode_spaces: bool) -> Vec<u8> {
195    let b = s.as_bytes();
196    let mut out: Vec<u8> = Vec::with_capacity(b.len());
197    let mut i = 0;
198    while i < b.len() {
199        match b[i] {
200            b'+' if decode_spaces => {
201                out.push(b' ');
202                i += 1;
203            }
204            b'%' if i + 2 < b.len() => {
205                let hi = (b[i + 1] as char).to_digit(16);
206                let lo = (b[i + 2] as char).to_digit(16);
207                match (hi, lo) {
208                    (Some(h), Some(l)) => {
209                        out.push((h * 16 + l) as u8);
210                        i += 3;
211                    }
212                    _ => {
213                        out.push(b'%');
214                        i += 1;
215                    }
216                }
217            }
218            c => {
219                out.push(c);
220                i += 1;
221            }
222        }
223    }
224    out
225}
226
227/// `querystring.escape` — percent-encode (space → `%20`, like Node; NOT `+`).
228fn escape(s: &str) -> String {
229    const UNRESERVED: &[u8] =
230        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()";
231    let mut out = String::with_capacity(s.len());
232    for &b in s.as_bytes() {
233        if UNRESERVED.contains(&b) {
234            out.push(b as char);
235        } else {
236            out.push('%');
237            out.push(
238                char::from_digit((b >> 4) as u32, 16)
239                    .unwrap()
240                    .to_ascii_uppercase(),
241            );
242            out.push(
243                char::from_digit((b & 0xf) as u32, 16)
244                    .unwrap()
245                    .to_ascii_uppercase(),
246            );
247        }
248    }
249    out
250}
251
252/// Reverse `escape` (`+` → space, `%XX` → byte). Malformed escapes pass through
253/// literally, as Node's `querystring.unescape` does (it never throws).
254/// `querystring.unescape(str)` — percent-decoding only.
255///
256/// A `+` stays a `+`. Only `parse` treats it as a space, because that is a
257/// form-encoding rule about the pair syntax, not about percent-escapes; this
258/// decoded it too, so `querystring.unescape('a+b')` gave `'a b'`.
259fn unescape(s: &str) -> String {
260    unescape_inner(s, false)
261}
262
263/// The parse-side decoder, which DOES read `+` as a space.
264fn unescape_form(s: &str) -> String {
265    unescape_inner(s, true)
266}
267
268fn unescape_inner(s: &str, plus_is_space: bool) -> String {
269    let bytes = s.as_bytes();
270    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
271    let mut i = 0;
272    while i < bytes.len() {
273        match bytes[i] {
274            b'+' if plus_is_space => {
275                out.push(b' ');
276                i += 1;
277            }
278            b'%' if i + 2 < bytes.len() => {
279                let hi = (bytes[i + 1] as char).to_digit(16);
280                let lo = (bytes[i + 2] as char).to_digit(16);
281                match (hi, lo) {
282                    (Some(h), Some(l)) => {
283                        out.push((h * 16 + l) as u8);
284                        i += 3;
285                    }
286                    _ => {
287                        out.push(b'%');
288                        i += 1;
289                    }
290                }
291            }
292            b => {
293                out.push(b);
294                i += 1;
295            }
296        }
297    }
298    String::from_utf8_lossy(&out).into_owned()
299}