From 66ad93277490b0440b6602e31d2389c5af35d1c5 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 15 Jul 2026 22:11:57 -0300 Subject: [PATCH] feat(core): recover repeated @httpQuery params via AwsRequest::query_param_all (bug-hunt 1.45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axum `Query>` extraction — and the `query_params` map on `AwsRequest` derived from it — collapses repeated query keys, keeping only the last value of `?Id=a&Id=b&Id=c`. That silently drops multi-value `@httpQuery` parameters (list-style filters / ids). `raw_query` already preserves the full query string, so this adds a first-class accessor `AwsRequest::query_param_all(key) -> Vec` that re-parses it and returns every occurrence in wire order, plus a `pub(crate)` `protocol::form_urlencoded_pairs` that decodes into ordered (key, value) pairs (reusing the existing UTF-8-correct `url_decode`). Handlers that need repeated keys can now recover them without a repo-wide change to the core representation. Tests: repeated keys preserved (vs the HashMap collapsing to the last value); percent/plus decoding. --- crates/fakecloud-core/src/protocol.rs | 45 +++++++++++++++++++++++++++ crates/fakecloud-core/src/service.rs | 13 ++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/fakecloud-core/src/protocol.rs b/crates/fakecloud-core/src/protocol.rs index ac7ec4ec7..cdb607af0 100644 --- a/crates/fakecloud-core/src/protocol.rs +++ b/crates/fakecloud-core/src/protocol.rs @@ -837,6 +837,26 @@ fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap } } +/// Decode a query / form-urlencoded string into ordered `(key, value)` pairs, +/// **preserving repeated keys**. The [`HashMap`] form +/// ([`decode_form_urlencoded`]) collapses `a=1&a=2` to a single entry, which +/// loses multi-value `@httpQuery` params; this variant keeps every occurrence +/// in wire order for callers that need them (e.g. list-style query params). +pub(crate) fn form_urlencoded_pairs(input: &str) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + for pair in input.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = match pair.find('=') { + Some(pos) => (&pair[..pos], &pair[pos + 1..]), + None => (pair, ""), + }; + pairs.push((url_decode(key), url_decode(value))); + } + pairs +} + fn decode_form_urlencoded(input: &[u8]) -> HashMap { let s = std::str::from_utf8(input).unwrap_or(""); let mut result = HashMap::new(); @@ -893,6 +913,31 @@ fn from_hex(b: u8) -> Option { mod tests { use super::*; + #[test] + fn form_urlencoded_pairs_preserves_repeated_keys() { + // The HashMap decoder collapses repeats; the ordered-pairs variant must + // keep every occurrence in wire order (1.45 multi-value @httpQuery). + let pairs = form_urlencoded_pairs("Id=a&Id=b&Id=c&Other=x"); + let ids: Vec<&str> = pairs + .iter() + .filter(|(k, _)| k == "Id") + .map(|(_, v)| v.as_str()) + .collect(); + assert_eq!(ids, vec!["a", "b", "c"]); + // Sanity: the HashMap form keeps only the last value. + assert_eq!( + decode_form_urlencoded(b"Id=a&Id=b&Id=c").get("Id").unwrap(), + "c" + ); + } + + #[test] + fn form_urlencoded_pairs_decodes_percent_and_plus() { + let pairs = form_urlencoded_pairs("q=caf%C3%A9+bar&empty="); + assert_eq!(pairs[0], ("q".to_string(), "café bar".to_string())); + assert_eq!(pairs[1], ("empty".to_string(), String::new())); + } + #[test] fn parse_amz_target_events() { let result = parse_amz_target("AWSEvents.PutEvents").unwrap(); diff --git a/crates/fakecloud-core/src/service.rs b/crates/fakecloud-core/src/service.rs index a215aa82d..d56419092 100644 --- a/crates/fakecloud-core/src/service.rs +++ b/crates/fakecloud-core/src/service.rs @@ -91,6 +91,19 @@ impl AwsRequest { pub fn take_body_stream(&self) -> Option { self.body_stream.lock().take() } + + /// All values supplied for a repeated `@httpQuery` parameter, in wire + /// order. [`AwsRequest::query_params`] is a `HashMap`, so `?a=1&a=2` keeps + /// only `2` there; this re-parses [`AwsRequest::raw_query`] to recover every + /// occurrence. Use it for list-style query params (filters, ids) where a + /// client legitimately repeats the key. Returns an empty vec when the key + /// is absent. + pub fn query_param_all(&self, key: &str) -> Vec { + crate::protocol::form_urlencoded_pairs(&self.raw_query) + .into_iter() + .filter_map(|(k, v)| (k == key).then_some(v)) + .collect() + } } /// Drain a streaming request body into a single [`Bytes`] buffer with no