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.
50fn parse(s: &str, args: &[Value]) -> Value {
51    let sep = args
52        .get(1)
53        .map(|_| super::arg_str(args, 1))
54        .filter(|s| !s.is_empty())
55        .unwrap_or_else(|| "&".into());
56    let eq = args
57        .get(2)
58        .map(|_| super::arg_str(args, 2))
59        .filter(|s| !s.is_empty())
60        .unwrap_or_else(|| "=".into());
61    let mut map: IndexMap<String, Value> = IndexMap::new();
62    if !s.is_empty() {
63        for pair in s.split(&sep) {
64            if pair.is_empty() {
65                continue;
66            }
67            let (k, v) = match pair.split_once(&eq) {
68                Some((k, v)) => (unescape(k), unescape(v)),
69                None => (unescape(pair), String::new()),
70            };
71            let val = with_host(|h| h.new_str(v));
72            // A repeated key promotes to (and then extends) an array.
73            match map.get(&k).cloned() {
74                Some(existing) => {
75                    let is_arr = with_host(|h| matches!(h.get(&existing), Some(JsObj::Array(_))));
76                    if is_arr {
77                        with_host(|h| {
78                            if let Some(JsObj::Array(items)) = h.get_mut(&existing) {
79                                items.push(val);
80                            }
81                        });
82                    } else {
83                        let arr = with_host(|h| h.new_array(vec![existing, val]));
84                        map.insert(k, arr);
85                    }
86                }
87                None => {
88                    map.insert(k, val);
89                }
90            }
91        }
92    }
93    with_host(|h| h.new_object(map))
94}
95
96/// `querystring.stringify(obj[, sep[, eq]])`.
97fn stringify(args: &[Value]) -> Value {
98    let obj = args.first().cloned().unwrap_or(Value::Undef);
99    let sep = args
100        .get(1)
101        .map(|_| super::arg_str(args, 1))
102        .filter(|s| !s.is_empty())
103        .unwrap_or_else(|| "&".into());
104    let eq = args
105        .get(2)
106        .map(|_| super::arg_str(args, 2))
107        .filter(|s| !s.is_empty())
108        .unwrap_or_else(|| "=".into());
109    let entries = with_host(|h| match h.get(&obj) {
110        Some(JsObj::Object(p)) => p
111            .iter()
112            .filter(|(k, _)| !k.starts_with("@@"))
113            .map(|(k, v)| (k.clone(), v.clone()))
114            .collect::<Vec<_>>(),
115        _ => Vec::new(),
116    });
117    let mut parts: Vec<String> = Vec::new();
118    for (k, v) in entries {
119        let ek = escape(&k);
120        // An array value emits one `key=elem` pair per element.
121        let elems = with_host(|h| match h.get(&v) {
122            Some(JsObj::Array(items)) => {
123                Some(items.iter().map(|x| h.str_of(x)).collect::<Vec<_>>())
124            }
125            _ => None,
126        });
127        match elems {
128            Some(list) => {
129                for e in list {
130                    parts.push(format!("{ek}{eq}{}", escape(&e)));
131                }
132            }
133            None => {
134                let ev = with_host(|h| h.str_of(&v));
135                parts.push(format!("{ek}{eq}{}", escape(&ev)));
136            }
137        }
138    }
139    with_host(|h| h.new_str(parts.join(&sep)))
140}
141
142/// `querystring.unescapeBuffer` core — decode `%XX` to raw bytes (and `+` to a
143/// space when `decode_spaces`), leaving malformed escapes literal.
144fn unescape_buffer(s: &str, decode_spaces: bool) -> Vec<u8> {
145    let b = s.as_bytes();
146    let mut out: Vec<u8> = Vec::with_capacity(b.len());
147    let mut i = 0;
148    while i < b.len() {
149        match b[i] {
150            b'+' if decode_spaces => {
151                out.push(b' ');
152                i += 1;
153            }
154            b'%' if i + 2 < b.len() => {
155                let hi = (b[i + 1] as char).to_digit(16);
156                let lo = (b[i + 2] as char).to_digit(16);
157                match (hi, lo) {
158                    (Some(h), Some(l)) => {
159                        out.push((h * 16 + l) as u8);
160                        i += 3;
161                    }
162                    _ => {
163                        out.push(b'%');
164                        i += 1;
165                    }
166                }
167            }
168            c => {
169                out.push(c);
170                i += 1;
171            }
172        }
173    }
174    out
175}
176
177/// `querystring.escape` — percent-encode (space → `%20`, like Node; NOT `+`).
178fn escape(s: &str) -> String {
179    const UNRESERVED: &[u8] =
180        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()";
181    let mut out = String::with_capacity(s.len());
182    for &b in s.as_bytes() {
183        if UNRESERVED.contains(&b) {
184            out.push(b as char);
185        } else {
186            out.push('%');
187            out.push(
188                char::from_digit((b >> 4) as u32, 16)
189                    .unwrap()
190                    .to_ascii_uppercase(),
191            );
192            out.push(
193                char::from_digit((b & 0xf) as u32, 16)
194                    .unwrap()
195                    .to_ascii_uppercase(),
196            );
197        }
198    }
199    out
200}
201
202/// Reverse `escape` (`+` → space, `%XX` → byte). Malformed escapes pass through
203/// literally, as Node's `querystring.unescape` does (it never throws).
204fn unescape(s: &str) -> String {
205    let bytes = s.as_bytes();
206    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
207    let mut i = 0;
208    while i < bytes.len() {
209        match bytes[i] {
210            b'+' => {
211                out.push(b' ');
212                i += 1;
213            }
214            b'%' if i + 2 < bytes.len() => {
215                let hi = (bytes[i + 1] as char).to_digit(16);
216                let lo = (bytes[i + 2] as char).to_digit(16);
217                match (hi, lo) {
218                    (Some(h), Some(l)) => {
219                        out.push((h * 16 + l) as u8);
220                        i += 3;
221                    }
222                    _ => {
223                        out.push(b'%');
224                        i += 1;
225                    }
226                }
227            }
228            b => {
229                out.push(b);
230                i += 1;
231            }
232        }
233    }
234    String::from_utf8_lossy(&out).into_owned()
235}