Skip to main content

armature_core/
query.rs

1//! Lazy query-string parsing.
2//!
3//! The old path parsed *and* percent-decoded the whole query into a
4//! `HashMap<String, String>` on every request that had one, whether or not any
5//! handler read it. This parses on first access and memoizes, so a handler that
6//! ignores the query pays nothing beyond carrying the raw bytes it already had.
7
8use smallvec::SmallVec;
9use std::borrow::Cow;
10use std::collections::HashMap;
11
12/// Parsed query pairs. Eight inline slots covers essentially all real queries.
13pub type QueryPairs = SmallVec<[(String, String); 8]>;
14
15/// A parsed view over a request's query string.
16///
17/// Borrowed from the request, so it cannot outlive it: the view hands out
18/// `&str` into the request's memoized pairs, never copies of them. The pairs
19/// themselves are owned — percent-decoding has to produce new bytes — but they
20/// are built once, on first access, not per lookup.
21#[derive(Debug, Clone, Copy)]
22pub struct QueryView<'a> {
23    pairs: &'a [(String, String)],
24}
25
26impl<'a> QueryView<'a> {
27    #[inline]
28    pub(crate) fn new(pairs: &'a [(String, String)]) -> Self {
29        Self { pairs }
30    }
31
32    /// The first value for `key`.
33    #[inline]
34    pub fn get(&self, key: &str) -> Option<&'a str> {
35        self.pairs
36            .iter()
37            .find(|(k, _)| k == key)
38            .map(|(_, v)| v.as_str())
39    }
40
41    /// Whether `key` appears at all.
42    #[inline]
43    pub fn contains_key(&self, key: &str) -> bool {
44        self.pairs.iter().any(|(k, _)| k == key)
45    }
46
47    /// Every value for `key`, in the order the client sent them.
48    #[inline]
49    pub fn get_all(&self, key: &'a str) -> impl Iterator<Item = &'a str> + 'a {
50        self.pairs
51            .iter()
52            .filter(move |(k, _)| k == key)
53            .map(|(_, v)| v.as_str())
54    }
55
56    /// Every pair, in the order the client sent them.
57    #[inline]
58    pub fn iter(&self) -> impl Iterator<Item = (&'a str, &'a str)> {
59        self.pairs.iter().map(|(k, v)| (k.as_str(), v.as_str()))
60    }
61
62    /// The number of pairs, counting repeated keys separately.
63    #[inline]
64    pub fn len(&self) -> usize {
65        self.pairs.len()
66    }
67
68    /// Whether the query carried any pairs.
69    #[inline]
70    pub fn is_empty(&self) -> bool {
71        self.pairs.is_empty()
72    }
73
74    /// An owned copy, for the call sites that genuinely need one.
75    ///
76    /// This is the allocation the lazy path exists to avoid — reach for it only
77    /// when a `HashMap` is actually required. Repeated keys collapse to the last
78    /// one, matching `HashMap`'s own insert semantics.
79    pub fn to_hash_map(&self) -> HashMap<String, String> {
80        self.pairs.iter().cloned().collect()
81    }
82}
83
84impl<'a> IntoIterator for QueryView<'a> {
85    type Item = (&'a str, &'a str);
86    type IntoIter = std::iter::Map<
87        std::slice::Iter<'a, (String, String)>,
88        fn(&'a (String, String)) -> (&'a str, &'a str),
89    >;
90
91    fn into_iter(self) -> Self::IntoIter {
92        fn as_strs(pair: &(String, String)) -> (&str, &str) {
93            (pair.0.as_str(), pair.1.as_str())
94        }
95        self.pairs.iter().map(as_strs as fn(_) -> _)
96    }
97}
98
99/// Parse `query` into key/value pairs, percent-decoding both sides.
100///
101/// Malformed input degrades rather than erroring: a bare key gets an empty
102/// value, and an escape that does not decode is preserved verbatim so the
103/// handler sees what the client sent. Rejecting a request over a stray `%` would
104/// break clients for no security gain — nothing downstream trusts these bytes.
105pub(crate) fn parse(query: &str) -> QueryPairs {
106    let mut out = QueryPairs::new();
107    for pair in query.split('&') {
108        if pair.is_empty() {
109            continue;
110        }
111        let (raw_key, raw_value) = match pair.split_once('=') {
112            Some((k, v)) => (k, v),
113            None => (pair, ""),
114        };
115        if raw_key.is_empty() {
116            continue;
117        }
118        out.push((decode(raw_key).into_owned(), decode(raw_value).into_owned()));
119    }
120    out
121}
122
123/// Percent- and plus-decode one component.
124///
125/// Returns `Cow::Borrowed` when there is nothing to decode, which is the common
126/// case, so the copy happens only for values that need it.
127fn decode(s: &str) -> Cow<'_, str> {
128    if !s.contains('%') && !s.contains('+') {
129        return Cow::Borrowed(s);
130    }
131
132    let bytes = s.as_bytes();
133    let mut out = Vec::with_capacity(bytes.len());
134    let mut i = 0;
135    while i < bytes.len() {
136        match bytes[i] {
137            b'+' => {
138                out.push(b' ');
139                i += 1;
140            }
141            b'%' => {
142                // `get` does the bounds check, so a truncated escape at the end
143                // of the input falls into the `None` arm rather than panicking.
144                match bytes
145                    .get(i + 1..i + 3)
146                    .and_then(|h| std::str::from_utf8(h).ok())
147                    .and_then(|h| u8::from_str_radix(h, 16).ok())
148                {
149                    Some(byte) => {
150                        out.push(byte);
151                        i += 3;
152                    }
153                    None => {
154                        // Not a valid escape. Keep it as written.
155                        out.push(b'%');
156                        i += 1;
157                    }
158                }
159            }
160            b => {
161                out.push(b);
162                i += 1;
163            }
164        }
165    }
166
167    match String::from_utf8(out) {
168        Ok(decoded) => Cow::Owned(decoded),
169        // Decoded to non-UTF-8: hand back the raw form rather than lossy text.
170        Err(_) => Cow::Borrowed(s),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use crate::HttpRequest;
177
178    #[test]
179    fn parses_on_first_access_and_decodes() {
180        let req = HttpRequest::new("GET", "/s?q=hello%20world&page=2");
181        let q = req.query();
182        assert_eq!(q.get("q"), Some("hello world"));
183        assert_eq!(q.get("page"), Some("2"));
184        assert_eq!(q.get("absent"), None);
185        assert_eq!(q.len(), 2);
186    }
187
188    #[test]
189    fn plus_is_a_space_and_percent_escapes_decode() {
190        let req = HttpRequest::new("GET", "/s?a=x+y&b=%2Fpath&c=%E2%9C%93");
191        let q = req.query();
192        assert_eq!(q.get("a"), Some("x y"));
193        assert_eq!(q.get("b"), Some("/path"));
194        assert_eq!(q.get("c"), Some("✓"));
195    }
196
197    #[test]
198    fn repeated_keys_are_all_reachable_and_get_returns_the_first() {
199        let req = HttpRequest::new("GET", "/s?tag=a&tag=b");
200        let q = req.query();
201        assert_eq!(q.get("tag"), Some("a"));
202        assert_eq!(q.get_all("tag").collect::<Vec<_>>(), vec!["a", "b"]);
203    }
204
205    #[test]
206    fn no_query_string_is_an_empty_view_not_a_panic() {
207        let req = HttpRequest::new("GET", "/s");
208        assert!(req.query().is_empty());
209        assert_eq!(req.query_string(), None);
210        assert_eq!(req.query_param("x"), None);
211    }
212
213    #[test]
214    fn malformed_input_degrades_rather_than_failing() {
215        // A bare key, an empty value, a stray '=', and a truncated escape. None of
216        // these is worth rejecting a request over, and all of them appear in real
217        // traffic.
218        let req = HttpRequest::new("GET", "/s?flag&empty=&=novalue&bad=%zz&trunc=%2");
219        let q = req.query();
220        assert_eq!(q.get("flag"), Some(""));
221        assert_eq!(q.get("empty"), Some(""));
222        // An undecodable escape is preserved verbatim rather than dropped, so a
223        // handler sees what the client actually sent.
224        assert_eq!(q.get("bad"), Some("%zz"));
225        assert_eq!(q.get("trunc"), Some("%2"));
226        // A pair with an empty key is dropped: there is nothing to look it up by.
227        assert_eq!(q.len(), 4);
228    }
229
230    #[test]
231    fn the_view_is_memoized_across_calls() {
232        let req = HttpRequest::new("GET", "/s?a=1");
233        let first = req.query().get("a").map(str::to_owned);
234        let second = req.query().get("a").map(str::to_owned);
235        assert_eq!(first, second);
236        // Same backing storage both times: the second call must not re-parse.
237        let p1 = req.query().iter().next().map(|(k, _)| k.as_ptr());
238        let p2 = req.query().iter().next().map(|(k, _)| k.as_ptr());
239        assert_eq!(p1, p2);
240    }
241
242    #[test]
243    fn cloning_a_request_does_not_carry_a_stale_cache() {
244        let req = HttpRequest::new("GET", "/s?a=1");
245        assert_eq!(req.query().get("a"), Some("1"));
246        let mut clone = req.clone();
247        clone.path = crate::ByteStr::from("/s?a=2");
248        // The clone's path changed, so its cache must not answer for the old one.
249        assert_eq!(clone.query().get("a"), Some("2"));
250    }
251}