Skip to content

Commit 198ea60

Browse files
committed
perf(delivery): Cow<str> values in query HashMap, drop into_owned()
form_urlencoded::parse returns Cow<str> pairs where keys and values that require no percent-decoding are Cow::Borrowed slices into the query string. Previously parse_query called into_owned() on every pair unconditionally, converting Borrowed slices to freshly-allocated Strings even when no decoding was needed. The HashMap type changes to HashMap<Cow<str>, Cow<str>>: - Clean alphanumeric keys/values (the common case for template params like 'port', 'name', 'env') stay Cow::Borrowed → zero String allocations per query parameter in parse_query. - Percent-encoded values produce Cow::Owned as before. - HashMap remains the storage type: O(1) SipHash lookup is preserved, and the DoS amplification vector (O(P × Q) linear scan) is still absent. Cow<str> implements Borrow<str>, so HashMap::get(&str) still works without any change at the call site in renderer::render. The renderer's replacement closure calls .as_ref().to_owned() to produce the required String — same allocation as before, but only for values that actually match a placeholder.
1 parent a187270 commit 198ea60

2 files changed

Lines changed: 20 additions & 10 deletions

File tree

src/delivery/mod.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,16 @@ fn etag_matches(inm: &HeaderValue, etag: &HeaderValue) -> bool {
265265
/// lookup in the renderer — a linear-scan structure would expose an
266266
/// O(placeholders × params) work factor exploitable by an attacker who sends
267267
/// arbitrarily many query parameters.
268-
fn parse_query(query: Option<&str>) -> HashMap<String, String> {
268+
///
269+
/// Keys and values that require no percent-decoding are stored as
270+
/// `Cow::Borrowed` slices into the query string, avoiding a heap allocation
271+
/// per parameter on the common clean-key path. Only percent-encoded
272+
/// characters produce `Cow::Owned` strings.
273+
fn parse_query(query: Option<&str>) -> HashMap<Cow<'_, str>, Cow<'_, str>> {
269274
let Some(query) = query else {
270275
return HashMap::new();
271276
};
272-
form_urlencoded::parse(query.as_bytes())
273-
.map(|(k, v)| (k.into_owned(), v.into_owned()))
274-
.collect()
277+
form_urlencoded::parse(query.as_bytes()).collect()
275278
}
276279

277280
/// Resolve the response MIME type: prefer the filename extension, falling back
@@ -323,8 +326,12 @@ mod tests {
323326
#[test]
324327
fn parse_query_decodes_and_dedupes() {
325328
let vars = parse_query(Some("port=8080&name=hello%20world&port=9090"));
326-
assert_eq!(vars.get("name").unwrap(), "hello world");
327-
assert_eq!(vars.get("port").unwrap(), "9090", "last value wins");
329+
assert_eq!(vars.get("name").unwrap().as_ref(), "hello world");
330+
assert_eq!(
331+
vars.get("port").unwrap().as_ref(),
332+
"9090",
333+
"last value wins"
334+
);
328335
}
329336

330337
#[test]

src/renderer.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ static PLACEHOLDER: LazyLock<Regex> =
2727
/// heap allocation. The caller may then serve the original bytes directly (e.g.
2828
/// via `bytes::Bytes::from_owner`) rather than copying.
2929
#[must_use]
30-
pub fn render<'a>(template: &'a str, variables: &HashMap<String, String>) -> Cow<'a, str> {
30+
pub fn render<'a>(
31+
template: &'a str,
32+
variables: &HashMap<Cow<'_, str>, Cow<'_, str>>,
33+
) -> Cow<'a, str> {
3134
PLACEHOLDER.replace_all(template, |caps: &Captures<'_>| {
3235
let key = &caps[1];
3336
match variables.get(key) {
34-
Some(value) => value.clone(),
37+
Some(value) => value.as_ref().to_owned(),
3538
// Leave the original `{{key}}` literally in place.
3639
None => caps[0].to_string(),
3740
}
@@ -42,10 +45,10 @@ pub fn render<'a>(template: &'a str, variables: &HashMap<String, String>) -> Cow
4245
mod tests {
4346
use super::*;
4447

45-
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
48+
fn vars<'a>(pairs: &[(&'a str, &'a str)]) -> HashMap<Cow<'a, str>, Cow<'a, str>> {
4649
pairs
4750
.iter()
48-
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
51+
.map(|(k, v)| (Cow::Borrowed(*k), Cow::Borrowed(*v)))
4952
.collect()
5053
}
5154

0 commit comments

Comments
 (0)