From 68845bd24d843670cd45e6decc60d01ac7266f40 Mon Sep 17 00:00:00 2001 From: kawaemon Date: Sat, 11 Jan 2025 03:26:52 +0900 Subject: [PATCH 01/52] fix: HeaderMap::reserve allocates insufficient capacity (#741) This bug caused additional allocation when attempted to insert the requested number of entries. This commit fix that by converting capacity to raw capacity before allocation. --- src/header/map.rs | 16 +++++++++------- tests/header_map.rs | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index 07b4554a..ebbc5937 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -707,20 +707,22 @@ impl HeaderMap { .checked_add(additional) .ok_or_else(MaxSizeReached::new)?; - if cap > self.indices.len() { - let cap = cap + let raw_cap = to_raw_capacity(cap); + + if raw_cap > self.indices.len() { + let raw_cap = raw_cap .checked_next_power_of_two() .ok_or_else(MaxSizeReached::new)?; - if cap > MAX_SIZE { + if raw_cap > MAX_SIZE { return Err(MaxSizeReached::new()); } if self.entries.is_empty() { - self.mask = cap as Size - 1; - self.indices = vec![Pos::none(); cap].into_boxed_slice(); - self.entries = Vec::with_capacity(usable_capacity(cap)); + self.mask = raw_cap as Size - 1; + self.indices = vec![Pos::none(); raw_cap].into_boxed_slice(); + self.entries = Vec::with_capacity(usable_capacity(raw_cap)); } else { - self.try_grow(cap)?; + self.try_grow(raw_cap)?; } } diff --git a/tests/header_map.rs b/tests/header_map.rs index 9859b0a8..9a9d7e12 100644 --- a/tests/header_map.rs +++ b/tests/header_map.rs @@ -63,6 +63,30 @@ fn reserve_overflow() { headers.reserve(std::usize::MAX); // next_power_of_two overflows } +#[test] +fn reserve() { + let mut headers = HeaderMap::::default(); + assert_eq!(headers.capacity(), 0); + + let requested_cap = 8; + headers.reserve(requested_cap); + + let reserved_cap = headers.capacity(); + assert!( + reserved_cap >= requested_cap, + "requested {} capacity, but it reserved only {} entries", + requested_cap, + reserved_cap, + ); + + for i in 0..requested_cap { + let name = format!("h{i}").parse::().unwrap(); + headers.insert(name, i); + } + + assert_eq!(headers.capacity(), reserved_cap, "unexpected reallocation"); +} + #[test] fn drain() { let mut headers = HeaderMap::new(); From a463fb5995d67e12b154474ede14a093db64f006 Mon Sep 17 00:00:00 2001 From: tottoto Date: Mon, 27 Jan 2025 22:29:37 +0900 Subject: [PATCH 02/52] chore(ci): use yq to get rust-version in manifest (#746) --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 365a11e2..7d8f39fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,9 +78,7 @@ jobs: - name: Get MSRV from package metadata id: metadata - run: | - cargo metadata --no-deps --format-version 1 | - jq -r '"msrv=" + (.packages[] | select(.name == "http")).rust_version' >> $GITHUB_OUTPUT + run: echo "msrv=$(yq '.package.rust-version' Cargo.toml)" >> $GITHUB_OUTPUT - name: Install Rust (${{ steps.metadata.outputs.msrv }}) uses: dtolnay/rust-toolchain@master From b03ed6a7e526a1c061dd2695be4ee14c177adf17 Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Mon, 3 Mar 2025 20:51:52 +0100 Subject: [PATCH 03/52] chore: use range.contains in StatusCode methods (#748) Small change for readability, alongside some minor documentation touchups for consistency. --- src/status.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/status.rs b/src/status.rs index 9ad04d20..7b3e8d64 100644 --- a/src/status.rs +++ b/src/status.rs @@ -44,7 +44,7 @@ use std::str::FromStr; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct StatusCode(NonZeroU16); -/// A possible error value when converting a `StatusCode` from a `u16` or `&str` +/// A possible error value when converting a `StatusCode` from a `u16` or `&str`. /// /// This error indicates that the supplied input was not a valid number, was less /// than 100, or was greater than 999. @@ -80,7 +80,7 @@ impl StatusCode { .ok_or_else(InvalidStatusCode::new) } - /// Converts a &[u8] to a status code + /// Converts a `&[u8]` to a status code. pub fn from_bytes(src: &[u8]) -> Result { if src.len() != 3 { return Err(InvalidStatusCode::new()); @@ -117,7 +117,7 @@ impl StatusCode { /// ``` #[inline] pub const fn as_u16(&self) -> u16 { - (*self).0.get() + self.0.get() } /// Returns a &str representation of the `StatusCode` @@ -175,31 +175,31 @@ impl StatusCode { /// Check if status is within 100-199. #[inline] pub fn is_informational(&self) -> bool { - 200 > self.0.get() && self.0.get() >= 100 + (100..200).contains(&self.0.get()) } /// Check if status is within 200-299. #[inline] pub fn is_success(&self) -> bool { - 300 > self.0.get() && self.0.get() >= 200 + (200..300).contains(&self.0.get()) } /// Check if status is within 300-399. #[inline] pub fn is_redirection(&self) -> bool { - 400 > self.0.get() && self.0.get() >= 300 + (300..400).contains(&self.0.get()) } /// Check if status is within 400-499. #[inline] pub fn is_client_error(&self) -> bool { - 500 > self.0.get() && self.0.get() >= 400 + (400..500).contains(&self.0.get()) } /// Check if status is within 500-599. #[inline] pub fn is_server_error(&self) -> bool { - 600 > self.0.get() && self.0.get() >= 500 + (500..600).contains(&self.0.get()) } } From 64bd92b9cc2d554ee72c519c243eed00446d84bb Mon Sep 17 00:00:00 2001 From: Jonathan Davies Date: Mon, 3 Mar 2025 19:58:09 +0000 Subject: [PATCH 04/52] docs: Fixed encryption/compression typo for 'accept-encoding: identity'. (#695) --- src/header/name.rs | 2 +- util/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/header/name.rs b/src/header/name.rs index 3d563f4e..122b96f9 100644 --- a/src/header/name.rs +++ b/src/header/name.rs @@ -203,7 +203,7 @@ standard_headers! { /// not to compress if a server use more than 80 % of its computational /// power. /// - /// As long as the identity value, meaning no encryption, is not explicitly + /// As long as the identity value, meaning no compression, is not explicitly /// forbidden, by an identity;q=0 or a *;q=0 without another explicitly set /// value for identity, the server must never send back a 406 Not Acceptable /// error. diff --git a/util/src/main.rs b/util/src/main.rs index 915cf0b8..336b2347 100644 --- a/util/src/main.rs +++ b/util/src/main.rs @@ -68,7 +68,7 @@ standard_headers! { /// not to compress if a server use more than 80 % of its computational /// power. /// - /// As long as the identity value, meaning no encryption, is not explicitly + /// As long as the identity value, meaning no compression, is not explicitly /// forbidden, by an identity;q=0 or a *;q=0 without another explicitly set /// value for identity, the server must never send back a 406 Not Acceptable /// error. From d0dd91e9b5d282b7837960747925a75340be83b2 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 11 Mar 2025 11:02:50 -0400 Subject: [PATCH 05/52] v1.3.0 --- CHANGELOG.md | 5 +++++ Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f74d6a..669936bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.3.0 (March 11, 2025) + +* Allow most UTF-8 characters in URI path and query. +* Fix `HeaderMap::reserve()` to allocate sufficient capacity. + # 1.2.0 (December 3, 2024) * Add `StatusCode::TOO_EARLY` constant for 425 status. diff --git a/Cargo.toml b/Cargo.toml index 1a6e9c9d..73a38cb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "http" # - Update html_root_url in lib.rs. # - Update CHANGELOG.md. # - Create git tag -version = "1.2.0" +version = "1.3.0" readme = "README.md" documentation = "https://docs.rs/http" repository = "https://github.com/hyperium/http" From 6637a728646d8dac8eedfb86447ffa82a1c5556b Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 11 Mar 2025 16:10:09 -0400 Subject: [PATCH 06/52] fix: validate path bytes are at least utf8 (#756) --- src/byte_str.rs | 6 ++++++ src/uri/path.rs | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/byte_str.rs b/src/byte_str.rs index 90872ecb..e69bf0a8 100644 --- a/src/byte_str.rs +++ b/src/byte_str.rs @@ -45,6 +45,12 @@ impl ByteStr { // Invariant: assumed by the safety requirements of this function. ByteStr { bytes } } + + pub(crate) fn from_utf8(bytes: Bytes) -> Result { + str::from_utf8(&bytes)?; + // Invariant: just checked is utf8 + Ok(ByteStr { bytes }) + } } impl ops::Deref for ByteStr { diff --git a/src/uri/path.rs b/src/uri/path.rs index df00c415..42db1f92 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -22,6 +22,8 @@ impl PathAndQuery { let mut query = NONE; let mut fragment = None; + let mut is_maybe_not_utf8 = false; + // block for iterator borrow { let mut iter = src.as_ref().iter().enumerate(); @@ -50,7 +52,12 @@ impl PathAndQuery { 0x40..=0x5F | 0x61..=0x7A | 0x7C | - 0x7E..=0xFF => {} + 0x7E => {} + + // potentially utf8, might not, should check + 0x7F..=0xFF => { + is_maybe_not_utf8 = true; + } // These are code points that are supposed to be // percent-encoded in the path but there are clients @@ -82,7 +89,11 @@ impl PathAndQuery { 0x21 | 0x24..=0x3B | 0x3D | - 0x3F..=0xFF => {} + 0x3F..=0x7E => {} + + 0x7F..=0xFF => { + is_maybe_not_utf8 = true; + } b'#' => { fragment = Some(i); @@ -99,10 +110,13 @@ impl PathAndQuery { src.truncate(i); } - Ok(PathAndQuery { - data: unsafe { ByteStr::from_utf8_unchecked(src) }, - query, - }) + let data = if is_maybe_not_utf8 { + ByteStr::from_utf8(src).map_err(|_| ErrorKind::InvalidUriChar)? + } else { + unsafe { ByteStr::from_utf8_unchecked(src) } + }; + + Ok(PathAndQuery { data, query }) } /// Convert a `PathAndQuery` from a static string. @@ -566,6 +580,16 @@ mod tests { assert_eq!(Some("pizza=🍕"), pq("/test?pizza=🍕").query()); } + #[test] + fn rejects_invalid_utf8_in_path() { + PathAndQuery::try_from(&[b'/', 0xFF][..]).expect_err("reject invalid utf8"); + } + + #[test] + fn rejects_invalid_utf8_in_query() { + PathAndQuery::try_from(&[b'/', b'a', b'?', 0xFF][..]).expect_err("reject invalid utf8"); + } + #[test] fn json_is_fine() { assert_eq!( From 8c1fb204b8594ce5cf198af8568453f9df4ed953 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 11 Mar 2025 16:12:23 -0400 Subject: [PATCH 07/52] v1.3.1 --- CHANGELOG.md | 4 ++++ Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 669936bf..dae64644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.3.1 (March 11, 2025) + +* Fix validation that all characters are UTF-8 in URI path and query. + # 1.3.0 (March 11, 2025) * Allow most UTF-8 characters in URI path and query. diff --git a/Cargo.toml b/Cargo.toml index 73a38cb1..29ee6c1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "http" # - Update html_root_url in lib.rs. # - Update CHANGELOG.md. # - Create git tag -version = "1.3.0" +version = "1.3.1" readme = "README.md" documentation = "https://docs.rs/http" repository = "https://github.com/hyperium/http" From 4304e604fc668baf675867d9045145f015f0957e Mon Sep 17 00:00:00 2001 From: Farzad Mohtasham <48632860+FarzadMohtasham@users.noreply.github.com> Date: Mon, 28 Apr 2025 22:57:57 +0330 Subject: [PATCH 08/52] tests: updated rand dependency to v0.9.1 (#763) --- Cargo.toml | 2 +- tests/header_map_fuzz.rs | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 29ee6c1c..97205378 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ itoa = "1" [dev-dependencies] quickcheck = "1" -rand = "0.8.0" +rand = "0.9.1" serde = "1.0" serde_json = "1.0" doc-comment = "0.3" diff --git a/tests/header_map_fuzz.rs b/tests/header_map_fuzz.rs index 40db0494..e6a43e8d 100644 --- a/tests/header_map_fuzz.rs +++ b/tests/header_map_fuzz.rs @@ -2,8 +2,8 @@ use http::header::*; use http::*; use quickcheck::{Arbitrary, Gen, QuickCheck, TestResult}; +use rand::prelude::IndexedRandom; use rand::rngs::StdRng; -use rand::seq::SliceRandom; use rand::{Rng, SeedableRng}; use std::collections::HashMap; @@ -76,12 +76,12 @@ impl Fuzz { let mut steps = vec![]; let mut expect = AltMap::default(); - let num = rng.gen_range(5..500); + let num = rng.random_range(5..500); let weight = Weight { - insert: rng.gen_range(1..10), - remove: rng.gen_range(1..10), - append: rng.gen_range(1..10), + insert: rng.random_range(1..10), + remove: rng.random_range(1..10), + append: rng.random_range(1..10), }; while steps.len() < num { @@ -112,7 +112,7 @@ impl Fuzz { impl Arbitrary for Fuzz { fn arbitrary(_: &mut Gen) -> Self { - Self::new(rand::thread_rng().gen()) + Self::new(rand::rng().random()) } } @@ -130,7 +130,7 @@ impl AltMap { fn gen_action(&mut self, weight: &Weight, rng: &mut StdRng) -> Action { let sum = weight.insert + weight.remove + weight.append; - let mut num = rng.gen_range(0..sum); + let mut num = rng.random_range(0..sum); if num < weight.insert { return self.gen_insert(rng); @@ -180,7 +180,7 @@ impl AltMap { /// Negative numbers weigh finding an existing header higher fn gen_name(&self, weight: i32, rng: &mut StdRng) -> HeaderName { - let mut existing = rng.gen_ratio(1, weight.abs() as u32); + let mut existing = rng.random_ratio(1, weight.abs() as u32); if weight < 0 { existing = !existing; @@ -202,7 +202,7 @@ impl AltMap { if self.map.is_empty() { None } else { - let n = rng.gen_range(0..self.map.len()); + let n = rng.random_range(0..self.map.len()); self.map.keys().nth(n).map(Clone::clone) } } @@ -337,7 +337,7 @@ fn gen_header_name(g: &mut StdRng) -> HeaderName { header::X_XSS_PROTECTION, ]; - if g.gen_ratio(1, 2) { + if g.random_ratio(1, 2) { STANDARD_HEADERS.choose(g).unwrap().clone() } else { let value = gen_string(g, 1, 25); From 181f73c7b5fbdb8b52ede01bb66335152eec721f Mon Sep 17 00:00:00 2001 From: Alex Bakon Date: Wed, 21 May 2025 15:42:13 -0400 Subject: [PATCH 09/52] Fix warnings on latest nightly (#769) Avoid "dangerous" implicit autoref creation. --- src/header/map.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index ebbc5937..ea12a96c 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -2315,7 +2315,7 @@ impl<'a, T> IterMut<'a, T> { self.cursor = Some(Cursor::Head); } - let entry = unsafe { &mut (*self.map).entries[self.entry] }; + let entry = &mut unsafe { &mut *self.map }.entries[self.entry]; match self.cursor.unwrap() { Head => { @@ -2323,7 +2323,7 @@ impl<'a, T> IterMut<'a, T> { Some((&entry.key, &mut entry.value as *mut _)) } Values(idx) => { - let extra = unsafe { &mut (*self.map).extra_values[idx] }; + let extra = &mut unsafe { &mut (*self.map) }.extra_values[idx]; match extra.next { Link::Entry(_) => self.cursor = None, @@ -2963,7 +2963,7 @@ impl<'a, T: 'a> Iterator for ValueIterMut<'a, T> { fn next(&mut self) -> Option { use self::Cursor::*; - let entry = unsafe { &mut (*self.map).entries[self.index] }; + let entry = &mut unsafe { &mut *self.map }.entries[self.index]; match self.front { Some(Head) => { @@ -2983,7 +2983,7 @@ impl<'a, T: 'a> Iterator for ValueIterMut<'a, T> { Some(&mut entry.value) } Some(Values(idx)) => { - let extra = unsafe { &mut (*self.map).extra_values[idx] }; + let extra = &mut unsafe { &mut *self.map }.extra_values[idx]; if self.front == self.back { self.front = None; @@ -3006,7 +3006,7 @@ impl<'a, T: 'a> DoubleEndedIterator for ValueIterMut<'a, T> { fn next_back(&mut self) -> Option { use self::Cursor::*; - let entry = unsafe { &mut (*self.map).entries[self.index] }; + let entry = &mut unsafe { &mut *self.map }.entries[self.index]; match self.back { Some(Head) => { @@ -3015,7 +3015,7 @@ impl<'a, T: 'a> DoubleEndedIterator for ValueIterMut<'a, T> { Some(&mut entry.value) } Some(Values(idx)) => { - let extra = unsafe { &mut (*self.map).extra_values[idx] }; + let extra = &mut unsafe { &mut *self.map }.extra_values[idx]; if self.front == self.back { self.front = None; From 613d3d465f222666327d61f5971133cad155f190 Mon Sep 17 00:00:00 2001 From: Alex Bakon Date: Fri, 23 May 2025 15:06:37 -0400 Subject: [PATCH 10/52] refactor: avoid unnecessary .expect()s for empty HeaderMap (#768) * Avoid unnecessary .expect()s for empty HeaderMap This change removes the Result::expect() calls in the constructors for an empty HeaderMap. These calls were provably not going to fail at runtime but rustc's inliner wasn't smart enough to figure that out: strings analysis of compiled binaries showed that the error message to the expect() still showed up in generated code. There are no behavioral differences as a result of this change. * Move new_empty() body into `Default` impl This gives us one fewer named method since we can use default() in place of new_empty(). It preserves the property that `HeaderMap::new()` constrains the generic type to `T=HeaderValue`. --- src/header/map.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index ea12a96c..4fa7b85a 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -445,8 +445,21 @@ impl HeaderMap { /// assert!(map.is_empty()); /// assert_eq!(0, map.capacity()); /// ``` + #[inline] pub fn new() -> Self { - HeaderMap::try_with_capacity(0).unwrap() + Self::default() + } +} + +impl Default for HeaderMap { + fn default() -> Self { + HeaderMap { + mask: 0, + indices: Box::new([]), // as a ZST, this doesn't actually allocate anything + entries: Vec::new(), + extra_values: Vec::new(), + danger: Danger::Green, + } } } @@ -501,13 +514,7 @@ impl HeaderMap { /// ``` pub fn try_with_capacity(capacity: usize) -> Result, MaxSizeReached> { if capacity == 0 { - Ok(HeaderMap { - mask: 0, - indices: Box::new([]), // as a ZST, this doesn't actually allocate anything - entries: Vec::new(), - extra_values: Vec::new(), - danger: Danger::Green, - }) + Ok(Self::default()) } else { let raw_cap = match to_raw_capacity(capacity).checked_next_power_of_two() { Some(c) => c, @@ -2164,12 +2171,6 @@ impl fmt::Debug for HeaderMap { } } -impl Default for HeaderMap { - fn default() -> Self { - HeaderMap::try_with_capacity(0).expect("zero capacity should never fail") - } -} - impl ops::Index for HeaderMap where K: AsHeaderName, From 5d98edce05d1585c2ffb6e743ddfc1165c3cdc04 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Mon, 14 Jul 2025 16:38:34 +0200 Subject: [PATCH 11/52] feat: show typenames in `Extensions` debug output (#773) --- src/extensions.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/extensions.rs b/src/extensions.rs index f16d762e..ed6f85c2 100644 --- a/src/extensions.rs +++ b/src/extensions.rs @@ -1,4 +1,4 @@ -use std::any::{Any, TypeId}; +use std::any::{type_name, Any, TypeId}; use std::collections::HashMap; use std::fmt; use std::hash::{BuildHasherDefault, Hasher}; @@ -267,7 +267,21 @@ impl Extensions { impl fmt::Debug for Extensions { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Extensions").finish() + struct TypeName(&'static str); + impl fmt::Debug for TypeName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } + } + + let mut set = f.debug_set(); + if let Some(map) = &self.map { + set.entries( + map.values() + .map(|any_clone| TypeName(any_clone.as_ref().type_name())), + ); + } + set.finish() } } @@ -276,6 +290,7 @@ trait AnyClone: Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; fn into_any(self: Box) -> Box; + fn type_name(&self) -> &'static str; } impl AnyClone for T { @@ -294,6 +309,10 @@ impl AnyClone for T { fn into_any(self: Box) -> Box { self } + + fn type_name(&self) -> &'static str { + type_name::() + } } impl Clone for Box { @@ -308,6 +327,7 @@ fn test_extensions() { struct MyType(i32); let mut extensions = Extensions::new(); + assert_eq!(format!("{extensions:?}"), "{}"); extensions.insert(5i32); extensions.insert(MyType(10)); @@ -315,6 +335,15 @@ fn test_extensions() { assert_eq!(extensions.get(), Some(&5i32)); assert_eq!(extensions.get_mut(), Some(&mut 5i32)); + let dbg = format!("{extensions:?}"); + // map order is NOT deterministic + assert!( + (dbg == "{http::extensions::test_extensions::MyType, i32}") + || (dbg == "{i32, http::extensions::test_extensions::MyType}"), + "{}", + dbg + ); + let ext2 = extensions.clone(); assert_eq!(extensions.remove::(), Some(5i32)); From b53194720352ef923d5fa662bc52592520e8b3ce Mon Sep 17 00:00:00 2001 From: Ell Date: Tue, 15 Jul 2025 20:57:18 +0300 Subject: [PATCH 12/52] docs: Clarify the HeaderMap documentation (#774) --- src/header/map.rs | 40 ++++++++++++++++++++++++++++++++++++++-- src/header/mod.rs | 46 ++++++---------------------------------------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index 4fa7b85a..e3f3d342 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -14,11 +14,47 @@ use super::HeaderValue; pub use self::as_header_name::AsHeaderName; pub use self::into_header_name::IntoHeaderName; -/// A set of HTTP headers +/// A specialized [multimap]() for +/// header names and values. /// -/// `HeaderMap` is a multimap of [`HeaderName`] to values. +/// # Overview +/// +/// `HeaderMap` is designed specifically for efficient manipulation of HTTP +/// headers. It supports multiple values per header name and provides +/// specialized APIs for insertion, retrieval, and iteration. +/// +/// The internal implementation is optimized for common usage patterns in HTTP, +/// and may change across versions. For example, the current implementation uses +/// [Robin Hood +/// hashing]() to +/// store entries compactly and enable high load factors with good performance. +/// However, the collision resolution strategy and storage mechanism are not +/// part of the public API and may be altered in future releases. +/// +/// # Iteration order +/// +/// Unless otherwise specified, the order in which items are returned by +/// iterators from `HeaderMap` methods is arbitrary; there is no guaranteed +/// ordering among the elements yielded by such an iterator. Changes to the +/// iteration order are not considered breaking changes, so users must not rely +/// on any incidental order produced by such an iterator. However, for a given +/// crate version, the iteration order will be consistent across all platforms. +/// +/// # Adaptive hashing +/// +/// `HeaderMap` uses an adaptive strategy for hashing to maintain fast lookups +/// while resisting hash collision attacks. The default hash function +/// prioritizes performance. In scenarios where high collision rates are +/// detected—typically indicative of denial-of-service attacks—the +/// implementation switches to a more secure, collision-resistant hash function. +/// +/// # Limitations +/// +/// A `HeaderMap` can store at most 32,768 entries \(header name/value pairs\). +/// Attempting to exceed this limit will result in a panic. /// /// [`HeaderName`]: struct.HeaderName.html +/// [`HeaderMap`]: struct.HeaderMap.html /// /// # Examples /// diff --git a/src/header/mod.rs b/src/header/mod.rs index 5d405767..1a5f1ede 100644 --- a/src/header/mod.rs +++ b/src/header/mod.rs @@ -28,47 +28,13 @@ //! //! # `HeaderMap` //! -//! `HeaderMap` is a map structure of header names highly optimized for use -//! cases common with HTTP. It is a [multimap] structure, where each header name -//! may have multiple associated header values. Given this, some of the APIs -//! diverge from [`HashMap`]. +//! The [`HeaderMap`] type is a specialized +//! [multimap]() structure for storing +//! header names and values. It is designed specifically for efficient +//! manipulation of HTTP headers. It supports multiple values per header name +//! and provides specialized APIs for insertion, retrieval, and iteration. //! -//! ## Overview -//! -//! Just like `HashMap` in Rust's stdlib, `HeaderMap` is based on [Robin Hood -//! hashing]. This algorithm tends to reduce the worst case search times in the -//! table and enables high load factors without seriously affecting performance. -//! Internally, keys and values are stored in vectors. As such, each insertion -//! will not incur allocation overhead. However, once the underlying vector -//! storage is full, a larger vector must be allocated and all values copied. -//! -//! ## Deterministic ordering -//! -//! Unlike Rust's `HashMap`, values in `HeaderMap` are deterministically -//! ordered. Roughly, values are ordered by insertion. This means that a -//! function that deterministically operates on a header map can rely on the -//! iteration order to remain consistent across processes and platforms. -//! -//! ## Adaptive hashing -//! -//! `HeaderMap` uses an adaptive hashing strategy in order to efficiently handle -//! most common cases. All standard headers have statically computed hash values -//! which removes the need to perform any hashing of these headers at runtime. -//! The default hash function emphasizes performance over robustness. However, -//! `HeaderMap` detects high collision rates and switches to a secure hash -//! function in those events. The threshold is set such that only denial of -//! service attacks should trigger it. -//! -//! ## Limitations -//! -//! `HeaderMap` can store a maximum of 32,768 headers (header name / value -//! pairs). Attempting to insert more will result in a panic. -//! -//! [`HeaderName`]: struct.HeaderName.html -//! [`HeaderMap`]: struct.HeaderMap.html -//! [multimap]: https://en.wikipedia.org/wiki/Multimap -//! [`HashMap`]: https://doc.rust-lang.org/std/collections/struct.HashMap.html -//! [Robin Hood hashing]: https://en.wikipedia.org/wiki/Hash_table#Robin_Hood_hashing +//! [*See also the `HeaderMap` type.*](HeaderMap) mod map; mod name; From 439d1c50d71e3be3204b6c4a1bf2255ed78e1f93 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 11 Jul 2025 09:35:21 -0400 Subject: [PATCH 13/52] chore: add FUNDING.yml --- .github/FUNDING.yml | 1 + src/version.rs | 5 +++-- tests/match_patterns.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 tests/match_patterns.rs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..00642f83 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: seanmonstar diff --git a/src/version.rs b/src/version.rs index d8b71306..b17c1dda 100644 --- a/src/version.rs +++ b/src/version.rs @@ -43,13 +43,14 @@ impl Version { } #[derive(PartialEq, PartialOrd, Copy, Clone, Eq, Ord, Hash)] +#[non_exhaustive] enum Http { Http09, Http10, Http11, H2, H3, - __NonExhaustive, + //__NonExhaustive, } impl Default for Version { @@ -69,7 +70,7 @@ impl fmt::Debug for Version { Http11 => "HTTP/1.1", H2 => "HTTP/2.0", H3 => "HTTP/3.0", - __NonExhaustive => unreachable!(), + //__NonExhaustive => unreachable!(), }) } } diff --git a/tests/match_patterns.rs b/tests/match_patterns.rs new file mode 100644 index 00000000..94e65fdc --- /dev/null +++ b/tests/match_patterns.rs @@ -0,0 +1,40 @@ +#[test] +fn match_scheme() { + let s = http::uri::Scheme::HTTP; + + match s { + http::uri::Scheme::HTTP => (), + http::uri::Scheme::HTTPS | _ => { + panic!("unexpected match: {:?}", s); + } + } +} + +#[test] +fn match_metcho() { + let m = "GET".parse::().unwrap(); + + match m { + http::Method::GET => (), + http::Method::POST | _ => { + panic!("unexpected match: {:?}", m); + } + } +} + +#[test] +fn match_status() { + +} + +#[test] +fn match_version() { + match http::Version::default() { + http::Version::HTTP_09 => (), + http::Version::HTTP_10 => (), + http::Version::HTTP_11 => (), + http::Version::HTTP_2 => (), + http::Version::HTTP_3 => (), + _ => (), + } +} From d9af49855e20a5a397abc4a236e735e702836d46 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 22 Oct 2025 11:45:59 -0400 Subject: [PATCH 14/52] revert: remove test code that shouldn't have been merged (#782) --- src/version.rs | 5 ++--- tests/match_patterns.rs | 40 ---------------------------------------- 2 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 tests/match_patterns.rs diff --git a/src/version.rs b/src/version.rs index b17c1dda..d8b71306 100644 --- a/src/version.rs +++ b/src/version.rs @@ -43,14 +43,13 @@ impl Version { } #[derive(PartialEq, PartialOrd, Copy, Clone, Eq, Ord, Hash)] -#[non_exhaustive] enum Http { Http09, Http10, Http11, H2, H3, - //__NonExhaustive, + __NonExhaustive, } impl Default for Version { @@ -70,7 +69,7 @@ impl fmt::Debug for Version { Http11 => "HTTP/1.1", H2 => "HTTP/2.0", H3 => "HTTP/3.0", - //__NonExhaustive => unreachable!(), + __NonExhaustive => unreachable!(), }) } } diff --git a/tests/match_patterns.rs b/tests/match_patterns.rs deleted file mode 100644 index 94e65fdc..00000000 --- a/tests/match_patterns.rs +++ /dev/null @@ -1,40 +0,0 @@ -#[test] -fn match_scheme() { - let s = http::uri::Scheme::HTTP; - - match s { - http::uri::Scheme::HTTP => (), - http::uri::Scheme::HTTPS | _ => { - panic!("unexpected match: {:?}", s); - } - } -} - -#[test] -fn match_metcho() { - let m = "GET".parse::().unwrap(); - - match m { - http::Method::GET => (), - http::Method::POST | _ => { - panic!("unexpected match: {:?}", m); - } - } -} - -#[test] -fn match_status() { - -} - -#[test] -fn match_version() { - match http::Version::default() { - http::Version::HTTP_09 => (), - http::Version::HTTP_10 => (), - http::Version::HTTP_11 => (), - http::Version::HTTP_2 => (), - http::Version::HTTP_3 => (), - _ => (), - } -} From 9f86d52c1310c485a3128f3c86c0ca7277fe5d92 Mon Sep 17 00:00:00 2001 From: Noa Date: Wed, 22 Oct 2025 11:05:19 -0500 Subject: [PATCH 15/52] feat: make `StatusCode::from_u16` const (#761) Co-authored-by: Sean McArthur --- src/status.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/status.rs b/src/status.rs index 7b3e8d64..0e3ca711 100644 --- a/src/status.rs +++ b/src/status.rs @@ -70,14 +70,13 @@ impl StatusCode { /// assert!(err.is_err()); /// ``` #[inline] - pub fn from_u16(src: u16) -> Result { - if !(100..1000).contains(&src) { - return Err(InvalidStatusCode::new()); + pub const fn from_u16(src: u16) -> Result { + if let 100..=999 = src { + if let Some(code) = NonZeroU16::new(src) { + return Ok(StatusCode(code)); + } } - - NonZeroU16::new(src) - .map(StatusCode) - .ok_or_else(InvalidStatusCode::new) + Err(InvalidStatusCode::new()) } /// Converts a `&[u8]` to a status code. @@ -523,7 +522,7 @@ status_codes! { } impl InvalidStatusCode { - fn new() -> InvalidStatusCode { + const fn new() -> InvalidStatusCode { InvalidStatusCode { _priv: () } } } From 8be674250393c3b1bd2600fb188ca78fc7e24f81 Mon Sep 17 00:00:00 2001 From: Zhixia Date: Thu, 23 Oct 2025 22:46:50 +0800 Subject: [PATCH 16/52] docs: Fix typo 'an' to 'and' in http::status module documentation (#784) Corrected a typo in the documentation comment. --- src/status.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/status.rs b/src/status.rs index 0e3ca711..e60ff945 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,6 +1,6 @@ //! HTTP status codes //! -//! This module contains HTTP-status code related structs an errors. The main +//! This module contains HTTP-status code related structs and errors. The main //! type in this module is `StatusCode` which is not intended to be used through //! this module but rather the `http::StatusCode` type. //! From 691af7258e25107a3d9d34dc4c8a88cfe46962b1 Mon Sep 17 00:00:00 2001 From: Raj Sarkar <144548552+AriajSarkar@users.noreply.github.com> Date: Mon, 27 Oct 2025 23:13:25 +0530 Subject: [PATCH 17/52] fix: update to_raw_capacity to return Result instead of panic (#787) --- src/header/map.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index e3f3d342..cdc23ae9 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -552,7 +552,8 @@ impl HeaderMap { if capacity == 0 { Ok(Self::default()) } else { - let raw_cap = match to_raw_capacity(capacity).checked_next_power_of_two() { + let raw_cap = to_raw_capacity(capacity)?; + let raw_cap = match raw_cap.checked_next_power_of_two() { Some(c) => c, None => return Err(MaxSizeReached { _priv: () }), }; @@ -750,7 +751,7 @@ impl HeaderMap { .checked_add(additional) .ok_or_else(MaxSizeReached::new)?; - let raw_cap = to_raw_capacity(cap); + let raw_cap = to_raw_capacity(cap)?; if raw_cap > self.indices.len() { let raw_cap = raw_cap @@ -3621,14 +3622,8 @@ fn usable_capacity(cap: usize) -> usize { } #[inline] -fn to_raw_capacity(n: usize) -> usize { - match n.checked_add(n / 3) { - Some(n) => n, - None => panic!( - "requested capacity {} too large: overflow while converting to raw capacity", - n - ), - } +fn to_raw_capacity(n: usize) -> Result { + n.checked_add(n / 3).ok_or_else(MaxSizeReached::new) } #[inline] From 56a365b6bf8b89eeb76bda4ffb99d3287192e833 Mon Sep 17 00:00:00 2001 From: Raj Sarkar <144548552+AriajSarkar@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:11:10 +0530 Subject: [PATCH 18/52] perf: optimize capacity reservation in HeaderMap's extend method (#788) --- src/header/map.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/header/map.rs b/src/header/map.rs index cdc23ae9..e670f484 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -2127,6 +2127,19 @@ impl Extend<(Option, T)> for HeaderMap { fn extend, T)>>(&mut self, iter: I) { let mut iter = iter.into_iter(); + // Reserve capacity similar to the (HeaderName, T) impl. + // Keys may be already present or show multiple times in the iterator. + // Reserve the entire hint lower bound if the map is empty. + // Otherwise reserve half the hint (rounded up), so the map + // will only resize twice in the worst case. + let reserve = if self.is_empty() { + iter.size_hint().0 + } else { + (iter.size_hint().0 + 1) / 2 + }; + + self.reserve(reserve); + // The structure of this is a bit weird, but it is mostly to make the // borrow checker happy. let (mut key, mut val) = match iter.next() { From 918bbc3c24535458cd2d5235f36f19b5ea229f0b Mon Sep 17 00:00:00 2001 From: claudecodering Date: Tue, 4 Nov 2025 21:24:55 +0800 Subject: [PATCH 19/52] chore: minor improvement for docs (#790) Signed-off-by: claudecodering --- src/header/name.rs | 2 +- src/header/value.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/header/name.rs b/src/header/name.rs index 122b96f9..ac701013 100644 --- a/src/header/name.rs +++ b/src/header/name.rs @@ -1245,7 +1245,7 @@ impl HeaderName { /// ```should_panic /// # use http::header::*; /// # - /// // Parsing a header that contains invalid symbols(s): + /// // Parsing a header that contains invalid symbols: /// HeaderName::from_static("content{}{}length"); // This line panics! /// /// // Parsing a header that contains invalid uppercase characters. diff --git a/src/header/value.rs b/src/header/value.rs index 99d1e155..de0758a8 100644 --- a/src/header/value.rs +++ b/src/header/value.rs @@ -15,7 +15,7 @@ use crate::header::name::HeaderName; /// HTTP spec allows for a header value to contain opaque bytes as well. In this /// case, the header field value is not able to be represented as a string. /// -/// To handle this, the `HeaderValue` is useable as a type and can be compared +/// To handle this, the `HeaderValue` is usable as a type and can be compared /// with strings and implements `Debug`. A `to_str` fn is provided that returns /// an `Err` if the header value contains non visible ascii characters. #[derive(Clone)] From 1888e28c544f8209f73c99b038dc0f645db34378 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 19 Nov 2025 11:43:44 -0500 Subject: [PATCH 20/52] tests: downgrade rand back to 0.8 for now Revert "tests: updated rand dependency to v0.9.1 (#763)" This reverts commit 4304e604fc668baf675867d9045145f015f0957e. --- Cargo.toml | 2 +- tests/header_map_fuzz.rs | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 97205378..29ee6c1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ itoa = "1" [dev-dependencies] quickcheck = "1" -rand = "0.9.1" +rand = "0.8.0" serde = "1.0" serde_json = "1.0" doc-comment = "0.3" diff --git a/tests/header_map_fuzz.rs b/tests/header_map_fuzz.rs index e6a43e8d..40db0494 100644 --- a/tests/header_map_fuzz.rs +++ b/tests/header_map_fuzz.rs @@ -2,8 +2,8 @@ use http::header::*; use http::*; use quickcheck::{Arbitrary, Gen, QuickCheck, TestResult}; -use rand::prelude::IndexedRandom; use rand::rngs::StdRng; +use rand::seq::SliceRandom; use rand::{Rng, SeedableRng}; use std::collections::HashMap; @@ -76,12 +76,12 @@ impl Fuzz { let mut steps = vec![]; let mut expect = AltMap::default(); - let num = rng.random_range(5..500); + let num = rng.gen_range(5..500); let weight = Weight { - insert: rng.random_range(1..10), - remove: rng.random_range(1..10), - append: rng.random_range(1..10), + insert: rng.gen_range(1..10), + remove: rng.gen_range(1..10), + append: rng.gen_range(1..10), }; while steps.len() < num { @@ -112,7 +112,7 @@ impl Fuzz { impl Arbitrary for Fuzz { fn arbitrary(_: &mut Gen) -> Self { - Self::new(rand::rng().random()) + Self::new(rand::thread_rng().gen()) } } @@ -130,7 +130,7 @@ impl AltMap { fn gen_action(&mut self, weight: &Weight, rng: &mut StdRng) -> Action { let sum = weight.insert + weight.remove + weight.append; - let mut num = rng.random_range(0..sum); + let mut num = rng.gen_range(0..sum); if num < weight.insert { return self.gen_insert(rng); @@ -180,7 +180,7 @@ impl AltMap { /// Negative numbers weigh finding an existing header higher fn gen_name(&self, weight: i32, rng: &mut StdRng) -> HeaderName { - let mut existing = rng.random_ratio(1, weight.abs() as u32); + let mut existing = rng.gen_ratio(1, weight.abs() as u32); if weight < 0 { existing = !existing; @@ -202,7 +202,7 @@ impl AltMap { if self.map.is_empty() { None } else { - let n = rng.random_range(0..self.map.len()); + let n = rng.gen_range(0..self.map.len()); self.map.keys().nth(n).map(Clone::clone) } } @@ -337,7 +337,7 @@ fn gen_header_name(g: &mut StdRng) -> HeaderName { header::X_XSS_PROTECTION, ]; - if g.random_ratio(1, 2) { + if g.gen_ratio(1, 2) { STANDARD_HEADERS.choose(g).unwrap().clone() } else { let value = gen_string(g, 1, 25); From e7a73372f56f803235f363de6c8fd43c9503b237 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 19 Nov 2025 10:31:56 -0500 Subject: [PATCH 21/52] chore: bump MSRV to 1.57 Motivation: to improve panic messages in const fns, which without this are a horrible experience for users. As we make more `from_static` constructors usable in a const context, this is worth the increase. We're still able to build on Debian oldstable. --- Cargo.toml | 5 ++--- README.md | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 29ee6c1c..1bc883aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,8 @@ A set of types for representing HTTP requests and responses. """ keywords = ["http"] categories = ["web-programming"] -edition = "2018" -# When updating this value, don't forget to also adjust the GitHub Actions config. -rust-version = "1.49.0" +edition = "2021" +rust-version = "1.57.0" [workspace] members = [ diff --git a/README.md b/README.md index a0090032..ab7425ee 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ fn main() { # Supported Rust Versions -This project follows the [Tokio MSRV][msrv] and is currently set to `1.49`. +This project follows the [hyper's MSRV _policy_][msrv], though it can be lower, and is currently set to `1.57`. -[msrv]: https://github.com/tokio-rs/tokio/#supported-rust-versions +[msrv]: https://hyper.rs/contrib/msrv/ # License From 20dbd6e54e95bb22386db3ca543c309100933087 Mon Sep 17 00:00:00 2001 From: Mateus Devino <19861348+mdevino@users.noreply.github.com> Date: Fri, 21 Nov 2025 13:20:20 -0300 Subject: [PATCH 22/52] feat(status): Add 103 EARLY_HINTS status code (#758) Signed-off-by: Mateus Devino --- src/status.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/status.rs b/src/status.rs index e60ff945..bc58ba45 100644 --- a/src/status.rs +++ b/src/status.rs @@ -333,6 +333,9 @@ status_codes! { /// 102 Processing /// [[RFC2518, Section 10.1](https://datatracker.ietf.org/doc/html/rfc2518#section-10.1)] (102, PROCESSING, "Processing"); + /// 103 Early Hints + /// [[RFC8297, Section 2](https://datatracker.ietf.org/doc/html/rfc8297#section-2)] + (103, EARLY_HINTS, "Early Hints"); /// 200 OK /// [[RFC9110, Section 15.3.1](https://datatracker.ietf.org/doc/html/rfc9110#section-15.3.1)] From fb1d4572eea2c6b47acc05f1bba0620ba22c9c67 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 21 Nov 2025 12:30:53 -0500 Subject: [PATCH 23/52] refactor(header): use better panic message in const HeaderName and HeaderValue (#797) --- src/header/name.rs | 31 ++----------------------------- src/header/value.rs | 30 +----------------------------- 2 files changed, 3 insertions(+), 58 deletions(-) diff --git a/src/header/name.rs b/src/header/name.rs index ac701013..1b4a39d6 100644 --- a/src/header/name.rs +++ b/src/header/name.rs @@ -1205,27 +1205,6 @@ impl HeaderName { /// /// This function panics when the static string is a invalid header. /// - /// Until [Allow panicking in constants](https://github.com/rust-lang/rfcs/pull/2345) - /// makes its way into stable, the panic message at compile-time is - /// going to look cryptic, but should at least point at your header value: - /// - /// ```text - /// error: any use of this value will cause an error - /// --> http/src/header/name.rs:1241:13 - /// | - /// 1241 | ([] as [u8; 0])[0]; // Invalid header name - /// | ^^^^^^^^^^^^^^^^^^ - /// | | - /// | index out of bounds: the length is 0 but the index is 0 - /// | inside `http::HeaderName::from_static` at http/src/header/name.rs:1241:13 - /// | inside `INVALID_NAME` at src/main.rs:3:34 - /// | - /// ::: src/main.rs:3:1 - /// | - /// 3 | const INVALID_NAME: HeaderName = HeaderName::from_static("Capitalized"); - /// | ------------------------------------------------------------------------ - /// ``` - /// /// # Examples /// /// ``` @@ -1252,7 +1231,6 @@ impl HeaderName { /// let a = HeaderName::from_static("foobar"); /// let b = HeaderName::from_static("FOOBAR"); // This line panics! /// ``` - #[allow(unconditional_panic)] // required for the panic circumvention pub const fn from_static(src: &'static str) -> HeaderName { let name_bytes = src.as_bytes(); if let Some(standard) = StandardHeader::from_bytes(name_bytes) { @@ -1272,13 +1250,8 @@ impl HeaderName { i += 1; } } { - // TODO: When msrv is bumped to larger than 1.57, this should be - // replaced with `panic!` macro. - // https://blog.rust-lang.org/2021/12/02/Rust-1.57.0.html#panic-in-const-contexts - // - // See the panics section of this method's document for details. - #[allow(clippy::no_effect, clippy::out_of_bounds_indexing)] - ([] as [u8; 0])[0]; // Invalid header name + // Invalid header name + panic!("HeaderName::from_static with invalid bytes") } HeaderName { diff --git a/src/header/value.rs b/src/header/value.rs index de0758a8..48308cb4 100644 --- a/src/header/value.rs +++ b/src/header/value.rs @@ -51,27 +51,6 @@ impl HeaderValue { /// This function panics if the argument contains invalid header value /// characters. /// - /// Until [Allow panicking in constants](https://github.com/rust-lang/rfcs/pull/2345) - /// makes its way into stable, the panic message at compile-time is - /// going to look cryptic, but should at least point at your header value: - /// - /// ```text - /// error: any use of this value will cause an error - /// --> http/src/header/value.rs:67:17 - /// | - /// 67 | ([] as [u8; 0])[0]; // Invalid header value - /// | ^^^^^^^^^^^^^^^^^^ - /// | | - /// | index out of bounds: the length is 0 but the index is 0 - /// | inside `HeaderValue::from_static` at http/src/header/value.rs:67:17 - /// | inside `INVALID_HEADER` at src/main.rs:73:33 - /// | - /// ::: src/main.rs:73:1 - /// | - /// 73 | const INVALID_HEADER: HeaderValue = HeaderValue::from_static("жsome value"); - /// | ---------------------------------------------------------------------------- - /// ``` - /// /// # Examples /// /// ``` @@ -80,19 +59,12 @@ impl HeaderValue { /// assert_eq!(val, "hello"); /// ``` #[inline] - #[allow(unconditional_panic)] // required for the panic circumvention pub const fn from_static(src: &'static str) -> HeaderValue { let bytes = src.as_bytes(); let mut i = 0; while i < bytes.len() { if !is_visible_ascii(bytes[i]) { - // TODO: When msrv is bumped to larger than 1.57, this should be - // replaced with `panic!` macro. - // https://blog.rust-lang.org/2021/12/02/Rust-1.57.0.html#panic-in-const-contexts - // - // See the panics section of this method's document for details. - #[allow(clippy::no_effect, clippy::out_of_bounds_indexing)] - ([] as [u8; 0])[0]; // Invalid header value + panic!("HeaderValue::from_static with invalid bytes") } i += 1; } From a7607679dcbe08339a2612ef6a1a6c5152726316 Mon Sep 17 00:00:00 2001 From: tottoto Date: Sun, 23 Nov 2025 21:28:27 +0900 Subject: [PATCH 24/52] docs: remove unnecessary extern crate sentence (#799) --- src/request.rs | 6 ------ src/response.rs | 6 ------ 2 files changed, 12 deletions(-) diff --git a/src/request.rs b/src/request.rs index 324b676c..0eb36b7d 100644 --- a/src/request.rs +++ b/src/request.rs @@ -118,9 +118,6 @@ use crate::{Extensions, Result, Uri}; /// Deserialize a request of bytes via json: /// /// ``` -/// # extern crate serde; -/// # extern crate serde_json; -/// # extern crate http; /// use http::Request; /// use serde::de; /// @@ -138,9 +135,6 @@ use crate::{Extensions, Result, Uri}; /// Or alternatively, serialize the body of a request to json /// /// ``` -/// # extern crate serde; -/// # extern crate serde_json; -/// # extern crate http; /// use http::Request; /// use serde::ser; /// diff --git a/src/response.rs b/src/response.rs index ab9e49bc..b8fc9b2a 100644 --- a/src/response.rs +++ b/src/response.rs @@ -140,9 +140,6 @@ use crate::{Extensions, Result}; /// Deserialize a response of bytes via json: /// /// ``` -/// # extern crate serde; -/// # extern crate serde_json; -/// # extern crate http; /// use http::Response; /// use serde::de; /// @@ -160,9 +157,6 @@ use crate::{Extensions, Result}; /// Or alternatively, serialize the body of a response to json /// /// ``` -/// # extern crate serde; -/// # extern crate serde_json; -/// # extern crate http; /// use http::Response; /// use serde::ser; /// From 0d7425146ea71b7ecb6bf9aa0be86c552ef481ce Mon Sep 17 00:00:00 2001 From: tottoto Date: Sun, 23 Nov 2025 21:28:51 +0900 Subject: [PATCH 25/52] chore(ci): update to actions/checkout@v5 (#800) --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d8f39fa..dfc4f031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -55,7 +55,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust (${{ matrix.rust }}) uses: dtolnay/rust-toolchain@master @@ -74,7 +74,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get MSRV from package metadata id: metadata @@ -96,7 +96,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -109,7 +109,7 @@ jobs: minimal-versions: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-hack @@ -122,7 +122,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust uses: dtolnay/rust-toolchain@nightly @@ -136,7 +136,7 @@ jobs: name: semver runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Check semver uses: obi1kenobi/cargo-semver-checks-action@v2 with: From b370d361c12350f170f3502f1338c5c2fc27350f Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Mon, 24 Nov 2025 23:39:23 +0800 Subject: [PATCH 26/52] feat(uri): make `Authority/PathAndQuery::from_static` const (#786) --- src/uri/authority.rs | 223 +++++++++++++++++++++++++------------------ src/uri/path.rs | 80 +++++++++++++++- 2 files changed, 208 insertions(+), 95 deletions(-) diff --git a/src/uri/authority.rs b/src/uri/authority.rs index 07aa6795..67754e45 100644 --- a/src/uri/authority.rs +++ b/src/uri/authority.rs @@ -8,6 +8,19 @@ use bytes::Bytes; use super::{ErrorKind, InvalidUri, Port, URI_CHARS}; use crate::byte_str::ByteStr; +/// Validation result for authority parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuthorityError { + Empty, + InvalidUriChar, + InvalidAuthority, + TooManyColons, + MismatchedBrackets, + InvalidBracketUsage, + EmptyAfterAt, + InvalidPercent, +} + /// Represents the authority component of a URI. #[derive(Clone)] pub struct Authority { @@ -45,9 +58,14 @@ impl Authority { /// let authority = Authority::from_static("example.com"); /// assert_eq!(authority.host(), "example.com"); /// ``` - pub fn from_static(src: &'static str) -> Self { - Authority::from_shared(Bytes::from_static(src.as_bytes())) - .expect("static str is not valid authority") + #[inline] + pub const fn from_static(src: &'static str) -> Self { + match validate_authority_bytes(src.as_bytes()) { + Ok(_) => Authority { + data: ByteStr::from_static(src), + }, + Err(_) => panic!("static str is not valid authority"), + } } /// Attempt to convert a `Bytes` buffer to a `Authority`. @@ -69,95 +87,19 @@ impl Authority { // Postcondition: for all Ok() returns, s[..ret.unwrap()] is valid UTF-8 where // ret is the return value. pub(super) fn parse(s: &[u8]) -> Result { - let mut colon_cnt = 0u32; - let mut start_bracket = false; - let mut end_bracket = false; - let mut has_percent = false; - let mut end = s.len(); - let mut at_sign_pos = None; - const MAX_COLONS: u32 = 8; // e.g., [FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80 - - // Among other things, this loop checks that every byte in s up to the - // first '/', '?', or '#' is a valid URI character (or in some contexts, - // a '%'). This means that each such byte is a valid single-byte UTF-8 - // code point. - for (i, &b) in s.iter().enumerate() { - match URI_CHARS[b as usize] { - b'/' | b'?' | b'#' => { - end = i; - break; - } - b':' => { - if colon_cnt >= MAX_COLONS { - return Err(ErrorKind::InvalidAuthority.into()); - } - colon_cnt += 1; - } - b'[' => { - if has_percent || start_bracket { - // Something other than the userinfo has a `%`, so reject it. - return Err(ErrorKind::InvalidAuthority.into()); - } - start_bracket = true; - } - b']' => { - if (!start_bracket) || end_bracket { - return Err(ErrorKind::InvalidAuthority.into()); - } - end_bracket = true; - - // Those were part of an IPv6 hostname, so forget them... - colon_cnt = 0; - has_percent = false; - } - b'@' => { - at_sign_pos = Some(i); - - // Those weren't a port colon, but part of the - // userinfo, so it needs to be forgotten. - colon_cnt = 0; - has_percent = false; - } - 0 if b == b'%' => { - // Per https://tools.ietf.org/html/rfc3986#section-3.2.1 and - // https://url.spec.whatwg.org/#authority-state - // the userinfo can have a percent-encoded username and password, - // so record that a `%` was found. If this turns out to be - // part of the userinfo, this flag will be cleared. - // Also per https://tools.ietf.org/html/rfc6874, percent-encoding can - // be used to indicate a zone identifier. - // If the flag hasn't been cleared at the end, that means this - // was part of the hostname (and not part of an IPv6 address), and - // will fail with an error. - has_percent = true; - } - 0 => { - return Err(ErrorKind::InvalidUriChar.into()); - } - _ => {} + validate_authority_bytes(s).map_err(|e| { + match e { + AuthorityError::Empty => ErrorKind::Empty, + AuthorityError::InvalidUriChar => ErrorKind::InvalidUriChar, + AuthorityError::InvalidAuthority + | AuthorityError::MismatchedBrackets + | AuthorityError::InvalidBracketUsage + | AuthorityError::EmptyAfterAt + | AuthorityError::InvalidPercent + | AuthorityError::TooManyColons => ErrorKind::InvalidAuthority, } - } - - if start_bracket ^ end_bracket { - return Err(ErrorKind::InvalidAuthority.into()); - } - - if colon_cnt > 1 { - // Things like 'localhost:8080:3030' are rejected. - return Err(ErrorKind::InvalidAuthority.into()); - } - - if end > 0 && at_sign_pos == Some(end - 1) { - // If there's nothing after an `@`, this is bonkers. - return Err(ErrorKind::InvalidAuthority.into()); - } - - if has_percent { - // Something after the userinfo has a `%`, so reject it. - return Err(ErrorKind::InvalidAuthority.into()); - } - - Ok(end) + .into() + }) } // Parse bytes as an Authority, not allowing an empty string. @@ -528,6 +470,105 @@ where }) } +/// Shared validation logic for authority bytes. +/// Returns the end position of valid authority bytes, or an error. +const fn validate_authority_bytes(s: &[u8]) -> Result { + if s.is_empty() { + return Err(AuthorityError::Empty); + } + + let mut colon_cnt: u32 = 0; + let mut start_bracket = false; + let mut end_bracket = false; + let mut has_percent = false; + let mut end = s.len(); + let mut at_sign_pos: usize = s.len(); + const MAX_COLONS: u32 = 8; // e.g., [FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80 + + let mut i = 0; + // Among other things, this loop checks that every byte in s up to the + // first '/', '?', or '#' is a valid URI character (or in some contexts, + // a '%'). This means that each such byte is a valid single-byte UTF-8 + // code point. + while i < s.len() { + let b = s[i]; + let ch = URI_CHARS[b as usize]; + + if ch == b'/' || ch == b'?' || ch == b'#' { + end = i; + break; + } + + if ch == 0 { + if b == b'%' { + // Per https://tools.ietf.org/html/rfc3986#section-3.2.1 and + // https://url.spec.whatwg.org/#authority-state + // the userinfo can have a percent-encoded username and password, + // so record that a `%` was found. If this turns out to be + // part of the userinfo, this flag will be cleared. + // Also per https://tools.ietf.org/html/rfc6874, percent-encoding can + // be used to indicate a zone identifier. + // If the flag hasn't been cleared at the end, that means this + // was part of the hostname (and not part of an IPv6 address), and + // will fail with an error. + has_percent = true; + } else { + return Err(AuthorityError::InvalidUriChar); + } + } else if ch == b':' { + if colon_cnt >= MAX_COLONS { + return Err(AuthorityError::TooManyColons); + } + colon_cnt += 1; + } else if ch == b'[' { + if has_percent || start_bracket { + // Something other than the userinfo has a `%`, so reject it. + return Err(AuthorityError::InvalidBracketUsage); + } + start_bracket = true; + } else if ch == b']' { + if !start_bracket || end_bracket { + return Err(AuthorityError::InvalidBracketUsage); + } + end_bracket = true; + + // Those were part of an IPv6 hostname, so forget them... + colon_cnt = 0; + has_percent = false; + } else if ch == b'@' { + at_sign_pos = i; + + // Those weren't a port colon, but part of the + // userinfo, so it needs to be forgotten. + colon_cnt = 0; + has_percent = false; + } + + i += 1; + } + + if start_bracket != end_bracket { + return Err(AuthorityError::MismatchedBrackets); + } + + if colon_cnt > 1 { + // Things like 'localhost:8080:3030' are rejected. + return Err(AuthorityError::InvalidAuthority); + } + + if end > 0 && at_sign_pos == end - 1 { + // If there's nothing after an `@`, this is bonkers. + return Err(AuthorityError::EmptyAfterAt); + } + + if has_percent { + // Something after the userinfo has a `%`, so reject it. + return Err(AuthorityError::InvalidPercent); + } + + Ok(end) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/uri/path.rs b/src/uri/path.rs index 42db1f92..8f9356e8 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -7,6 +7,14 @@ use bytes::Bytes; use super::{ErrorKind, InvalidUri}; use crate::byte_str::ByteStr; +/// Validation result for path and query parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathAndQueryError { + InvalidPathChar, + InvalidQueryChar, + FragmentNotAllowed, +} + /// Represents the path component of a URI #[derive(Clone)] pub struct PathAndQuery { @@ -138,10 +146,14 @@ impl PathAndQuery { /// assert_eq!(v.query(), Some("world")); /// ``` #[inline] - pub fn from_static(src: &'static str) -> Self { - let src = Bytes::from_static(src.as_bytes()); - - PathAndQuery::from_shared(src).unwrap() + pub const fn from_static(src: &'static str) -> Self { + match validate_path_and_query_bytes(src.as_bytes()) { + Ok(query) => PathAndQuery { + data: ByteStr::from_static(src), + query, + }, + Err(_) => panic!("static str is not valid path"), + } } /// Attempt to convert a `Bytes` buffer to a `PathAndQuery`. @@ -467,6 +479,66 @@ impl PartialOrd for String { } } +/// Shared validation logic for path and query bytes. +/// Returns the query position (or NONE), or an error. +const fn validate_path_and_query_bytes(bytes: &[u8]) -> Result { + let mut query: u16 = NONE; + let mut i: usize = 0; + + // path ... + while i < bytes.len() { + let b = bytes[i]; + if b == b'?' { + query = i as u16; + i += 1; + break; + } else if b == b'#' { + return Err(PathAndQueryError::FragmentNotAllowed); + } else { + let allowed = b == 0x21 + || (b >= 0x24 && b <= 0x3B) + || b == 0x3D + || (b >= 0x40 && b <= 0x5F) + || (b >= 0x61 && b <= 0x7A) + || b == 0x7C + || b == 0x7E + || b == b'"' + || b == b'{' + || b == b'}' + || (b >= 0x7F); + + if !allowed { + return Err(PathAndQueryError::InvalidPathChar); + } + } + i += 1; + } + + // query ... + if query != NONE { + while i < bytes.len() { + let b = bytes[i]; + if b == b'#' { + return Err(PathAndQueryError::FragmentNotAllowed); + } + + let allowed = b == 0x21 + || (b >= 0x24 && b <= 0x3B) + || b == 0x3D + || (b >= 0x3F && b <= 0x7E) + || (b >= 0x7F); + + if !allowed { + return Err(PathAndQueryError::InvalidQueryChar); + } + + i += 1; + } + } + + Ok(query) +} + #[cfg(test)] mod tests { use super::*; From 50b009c367dd9735f98bc3e4f5dd19acc629dfc5 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 24 Nov 2025 10:47:01 -0500 Subject: [PATCH 27/52] refactor(header): inline FNV hasher to reduce dependencies (#796) --- Cargo.toml | 1 - src/header/map.rs | 30 +++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1bc883aa..c66c6fa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,6 @@ std = [] [dependencies] bytes = "1" -fnv = "1.0.5" itoa = "1" [dev-dependencies] diff --git a/src/header/map.rs b/src/header/map.rs index e670f484..af6b623b 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -3654,8 +3654,6 @@ fn hash_elem_using(danger: &Danger, k: &K) -> HashValue where K: Hash + ?Sized, { - use fnv::FnvHasher; - const MASK: u64 = (MAX_SIZE as u64) - 1; let hash = match *danger { @@ -3667,7 +3665,7 @@ where } // Fast hash _ => { - let mut h = FnvHasher::default(); + let mut h = FnvHasher::new(); k.hash(&mut h); h.finish() } @@ -3676,6 +3674,32 @@ where HashValue((hash & MASK) as u16) } +struct FnvHasher(u64); + +impl FnvHasher { + #[inline] + fn new() -> Self { + FnvHasher(0xcbf29ce484222325) + } +} + +impl std::hash::Hasher for FnvHasher { + #[inline] + fn finish(&self) -> u64 { + self.0 + } + + #[inline] + fn write(&mut self, bytes: &[u8]) { + let mut hash = self.0; + for &b in bytes { + hash = hash ^ (b as u64); + hash = hash.wrapping_mul(0x100000001b3); + } + self.0 = hash; + } +} + /* * * ===== impl IntoHeaderName / AsHeaderName ===== From b9625d83b524f7a8306883484f29a746eefc1bab Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 24 Nov 2025 10:49:04 -0500 Subject: [PATCH 28/52] v1.4.0 --- CHANGELOG.md | 8 ++++++++ Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae64644..34f393c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.4.0 (November 24, 2025) + +- Add `StatusCode::EARLY_HINTS` constant for 103 Early Hints. +- Make `StatusCode::from_u16` now a `const fn`. +- Make `Authority::from_static` now a `const fn`. +- Make `PathAndQuery::from_static` now a `const fn`. +- MSRV increased to 1.57 (allows legible const fn panic messages). + # 1.3.1 (March 11, 2025) * Fix validation that all characters are UTF-8 in URI path and query. diff --git a/Cargo.toml b/Cargo.toml index c66c6fa4..1c8906f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "http" # - Update html_root_url in lib.rs. # - Update CHANGELOG.md. # - Create git tag -version = "1.3.1" +version = "1.4.0" readme = "README.md" documentation = "https://docs.rs/http" repository = "https://github.com/hyperium/http" From bc717805fda099701d7ad2145981ee4b16110fd4 Mon Sep 17 00:00:00 2001 From: rxc-amzn <148253733+rxc-amzn@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:24:26 +0000 Subject: [PATCH 29/52] chore(header): fix clippy::assign_op_pattern (#806) --- src/header/map.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/header/map.rs b/src/header/map.rs index af6b623b..aefbb003 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -3693,7 +3693,7 @@ impl std::hash::Hasher for FnvHasher { fn write(&mut self, bytes: &[u8]) { let mut hash = self.0; for &b in bytes { - hash = hash ^ (b as u64); + hash ^= b as u64; hash = hash.wrapping_mul(0x100000001b3); } self.0 = hash; From e9de46c9269f0a476b34a02a401212e20f639df2 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 5 Jan 2026 09:06:15 -0500 Subject: [PATCH 30/52] ci: pin itoa in msrv job (#813) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc4f031..1b25d696 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: with: toolchain: ${{ steps.metadata.outputs.msrv }} + - name: Pin deps + run: cargo update -p itoa --precise 1.0.15 + - name: Test run: cargo check -p http From 5f0c86642f1dc86f156da82b62aceb2f4fab20e1 Mon Sep 17 00:00:00 2001 From: Jonas Platte Date: Mon, 12 Jan 2026 22:21:43 +0100 Subject: [PATCH 31/52] style: remove unnecessary explicit lifetimes (#815) Partially automated with cargo clippy --fix. --- src/byte_str.rs | 4 ++-- src/header/map.rs | 16 ++++++++-------- src/header/name.rs | 28 ++++++++++++++-------------- src/header/value.rs | 32 ++++++++++++++++---------------- src/method.rs | 24 ++++++++++++------------ src/status.rs | 12 ++++++------ src/uri/authority.rs | 20 ++++++++++---------- src/uri/mod.rs | 22 +++++++++++----------- src/uri/path.rs | 20 ++++++++++---------- src/uri/scheme.rs | 8 ++++---- 10 files changed, 93 insertions(+), 93 deletions(-) diff --git a/src/byte_str.rs b/src/byte_str.rs index e69bf0a8..f285e7f2 100644 --- a/src/byte_str.rs +++ b/src/byte_str.rs @@ -74,9 +74,9 @@ impl From for ByteStr { } } -impl<'a> From<&'a str> for ByteStr { +impl From<&str> for ByteStr { #[inline] - fn from(src: &'a str) -> ByteStr { + fn from(src: &str) -> ByteStr { ByteStr { // Invariant: src is a str so contains valid UTF-8. bytes: Bytes::copy_from_slice(src.as_bytes()), diff --git a/src/header/map.rs b/src/header/map.rs index aefbb003..85504d14 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -3758,7 +3758,7 @@ mod into_header_name { impl IntoHeaderName for HeaderName {} - impl<'a> Sealed for &'a HeaderName { + impl Sealed for &HeaderName { #[inline] fn try_insert( self, @@ -3778,7 +3778,7 @@ mod into_header_name { } } - impl<'a> IntoHeaderName for &'a HeaderName {} + impl IntoHeaderName for &HeaderName {} impl Sealed for &'static str { #[inline] @@ -3868,7 +3868,7 @@ mod as_header_name { impl AsHeaderName for HeaderName {} - impl<'a> Sealed for &'a HeaderName { + impl Sealed for &HeaderName { #[inline] fn try_entry(self, map: &mut HeaderMap) -> Result, TryEntryError> { Ok(map.try_entry2(self)?) @@ -3884,9 +3884,9 @@ mod as_header_name { } } - impl<'a> AsHeaderName for &'a HeaderName {} + impl AsHeaderName for &HeaderName {} - impl<'a> Sealed for &'a str { + impl Sealed for &str { #[inline] fn try_entry(self, map: &mut HeaderMap) -> Result, TryEntryError> { Ok(HdrName::from_bytes(self.as_bytes(), move |hdr| { @@ -3904,7 +3904,7 @@ mod as_header_name { } } - impl<'a> AsHeaderName for &'a str {} + impl AsHeaderName for &str {} impl Sealed for String { #[inline] @@ -3924,7 +3924,7 @@ mod as_header_name { impl AsHeaderName for String {} - impl<'a> Sealed for &'a String { + impl Sealed for &String { #[inline] fn try_entry(self, map: &mut HeaderMap) -> Result, TryEntryError> { self.as_str().try_entry(map) @@ -3940,7 +3940,7 @@ mod as_header_name { } } - impl<'a> AsHeaderName for &'a String {} + impl AsHeaderName for &String {} } #[test] diff --git a/src/header/name.rs b/src/header/name.rs index 1b4a39d6..02af57e1 100644 --- a/src/header/name.rs +++ b/src/header/name.rs @@ -1319,8 +1319,8 @@ impl InvalidHeaderName { } } -impl<'a> From<&'a HeaderName> for HeaderName { - fn from(src: &'a HeaderName) -> HeaderName { +impl From<&HeaderName> for HeaderName { + fn from(src: &HeaderName) -> HeaderName { src.clone() } } @@ -1345,26 +1345,26 @@ impl From for Bytes { } } -impl<'a> TryFrom<&'a str> for HeaderName { +impl TryFrom<&str> for HeaderName { type Error = InvalidHeaderName; #[inline] - fn try_from(s: &'a str) -> Result { + fn try_from(s: &str) -> Result { Self::from_bytes(s.as_bytes()) } } -impl<'a> TryFrom<&'a String> for HeaderName { +impl TryFrom<&String> for HeaderName { type Error = InvalidHeaderName; #[inline] - fn try_from(s: &'a String) -> Result { + fn try_from(s: &String) -> Result { Self::from_bytes(s.as_bytes()) } } -impl<'a> TryFrom<&'a [u8]> for HeaderName { +impl TryFrom<&[u8]> for HeaderName { type Error = InvalidHeaderName; #[inline] - fn try_from(s: &'a [u8]) -> Result { + fn try_from(s: &[u8]) -> Result { Self::from_bytes(s) } } @@ -1405,14 +1405,14 @@ impl From for HeaderName { } } -impl<'a> PartialEq<&'a HeaderName> for HeaderName { +impl PartialEq<&HeaderName> for HeaderName { #[inline] - fn eq(&self, other: &&'a HeaderName) -> bool { + fn eq(&self, other: &&HeaderName) -> bool { *self == **other } } -impl<'a> PartialEq for &'a HeaderName { +impl PartialEq for &HeaderName { #[inline] fn eq(&self, other: &HeaderName) -> bool { *other == *self @@ -1457,16 +1457,16 @@ impl PartialEq for str { } } -impl<'a> PartialEq<&'a str> for HeaderName { +impl PartialEq<&str> for HeaderName { /// Performs a case-insensitive comparison of the string against the header /// name #[inline] - fn eq(&self, other: &&'a str) -> bool { + fn eq(&self, other: &&str) -> bool { *self == **other } } -impl<'a> PartialEq for &'a str { +impl PartialEq for &str { /// Performs a case-insensitive comparison of the string against the header /// name #[inline] diff --git a/src/header/value.rs b/src/header/value.rs index 48308cb4..abd5d036 100644 --- a/src/header/value.rs +++ b/src/header/value.rs @@ -484,35 +484,35 @@ impl FromStr for HeaderValue { } } -impl<'a> From<&'a HeaderValue> for HeaderValue { +impl From<&HeaderValue> for HeaderValue { #[inline] - fn from(t: &'a HeaderValue) -> Self { + fn from(t: &HeaderValue) -> Self { t.clone() } } -impl<'a> TryFrom<&'a str> for HeaderValue { +impl TryFrom<&str> for HeaderValue { type Error = InvalidHeaderValue; #[inline] - fn try_from(t: &'a str) -> Result { + fn try_from(t: &str) -> Result { t.parse() } } -impl<'a> TryFrom<&'a String> for HeaderValue { +impl TryFrom<&String> for HeaderValue { type Error = InvalidHeaderValue; #[inline] - fn try_from(s: &'a String) -> Result { + fn try_from(s: &String) -> Result { Self::from_bytes(s.as_bytes()) } } -impl<'a> TryFrom<&'a [u8]> for HeaderValue { +impl TryFrom<&[u8]> for HeaderValue { type Error = InvalidHeaderValue; #[inline] - fn try_from(t: &'a [u8]) -> Result { + fn try_from(t: &[u8]) -> Result { HeaderValue::from_bytes(t) } } @@ -697,48 +697,48 @@ impl PartialOrd for String { } } -impl<'a> PartialEq for &'a HeaderValue { +impl PartialEq for &HeaderValue { #[inline] fn eq(&self, other: &HeaderValue) -> bool { **self == *other } } -impl<'a> PartialOrd for &'a HeaderValue { +impl PartialOrd for &HeaderValue { #[inline] fn partial_cmp(&self, other: &HeaderValue) -> Option { (**self).partial_cmp(other) } } -impl<'a, T: ?Sized> PartialEq<&'a T> for HeaderValue +impl PartialEq<&T> for HeaderValue where HeaderValue: PartialEq, { #[inline] - fn eq(&self, other: &&'a T) -> bool { + fn eq(&self, other: &&T) -> bool { *self == **other } } -impl<'a, T: ?Sized> PartialOrd<&'a T> for HeaderValue +impl PartialOrd<&T> for HeaderValue where HeaderValue: PartialOrd, { #[inline] - fn partial_cmp(&self, other: &&'a T) -> Option { + fn partial_cmp(&self, other: &&T) -> Option { self.partial_cmp(*other) } } -impl<'a> PartialEq for &'a str { +impl PartialEq for &str { #[inline] fn eq(&self, other: &HeaderValue) -> bool { *other == *self } } -impl<'a> PartialOrd for &'a str { +impl PartialOrd for &str { #[inline] fn partial_cmp(&self, other: &HeaderValue) -> Option { self.as_bytes().partial_cmp(other.as_bytes()) diff --git a/src/method.rs b/src/method.rs index 7b4584ab..3f2c6bcb 100644 --- a/src/method.rs +++ b/src/method.rs @@ -187,14 +187,14 @@ impl AsRef for Method { } } -impl<'a> PartialEq<&'a Method> for Method { +impl PartialEq<&Method> for Method { #[inline] - fn eq(&self, other: &&'a Method) -> bool { + fn eq(&self, other: &&Method) -> bool { self == *other } } -impl<'a> PartialEq for &'a Method { +impl PartialEq for &Method { #[inline] fn eq(&self, other: &Method) -> bool { *self == other @@ -215,14 +215,14 @@ impl PartialEq for str { } } -impl<'a> PartialEq<&'a str> for Method { +impl PartialEq<&str> for Method { #[inline] - fn eq(&self, other: &&'a str) -> bool { + fn eq(&self, other: &&str) -> bool { self.as_ref() == *other } } -impl<'a> PartialEq for &'a str { +impl PartialEq for &str { #[inline] fn eq(&self, other: &Method) -> bool { *self == other.as_ref() @@ -248,27 +248,27 @@ impl Default for Method { } } -impl<'a> From<&'a Method> for Method { +impl From<&Method> for Method { #[inline] - fn from(t: &'a Method) -> Self { + fn from(t: &Method) -> Self { t.clone() } } -impl<'a> TryFrom<&'a [u8]> for Method { +impl TryFrom<&[u8]> for Method { type Error = InvalidMethod; #[inline] - fn try_from(t: &'a [u8]) -> Result { + fn try_from(t: &[u8]) -> Result { Method::from_bytes(t) } } -impl<'a> TryFrom<&'a str> for Method { +impl TryFrom<&str> for Method { type Error = InvalidMethod; #[inline] - fn try_from(t: &'a str) -> Result { + fn try_from(t: &str) -> Result { TryFrom::try_from(t.as_bytes()) } } diff --git a/src/status.rs b/src/status.rs index bc58ba45..aa9dc308 100644 --- a/src/status.rs +++ b/src/status.rs @@ -263,27 +263,27 @@ impl FromStr for StatusCode { } } -impl<'a> From<&'a StatusCode> for StatusCode { +impl From<&StatusCode> for StatusCode { #[inline] - fn from(t: &'a StatusCode) -> Self { + fn from(t: &StatusCode) -> Self { t.to_owned() } } -impl<'a> TryFrom<&'a [u8]> for StatusCode { +impl TryFrom<&[u8]> for StatusCode { type Error = InvalidStatusCode; #[inline] - fn try_from(t: &'a [u8]) -> Result { + fn try_from(t: &[u8]) -> Result { StatusCode::from_bytes(t) } } -impl<'a> TryFrom<&'a str> for StatusCode { +impl TryFrom<&str> for StatusCode { type Error = InvalidStatusCode; #[inline] - fn try_from(t: &'a str) -> Result { + fn try_from(t: &str) -> Result { t.parse() } } diff --git a/src/uri/authority.rs b/src/uri/authority.rs index 67754e45..c5479cb2 100644 --- a/src/uri/authority.rs +++ b/src/uri/authority.rs @@ -244,14 +244,14 @@ impl PartialEq for str { } } -impl<'a> PartialEq for &'a str { +impl PartialEq for &str { fn eq(&self, other: &Authority) -> bool { self.eq_ignore_ascii_case(other.as_str()) } } -impl<'a> PartialEq<&'a str> for Authority { - fn eq(&self, other: &&'a str) -> bool { +impl PartialEq<&str> for Authority { + fn eq(&self, other: &&str) -> bool { self.data.eq_ignore_ascii_case(other) } } @@ -302,7 +302,7 @@ impl PartialOrd for str { } } -impl<'a> PartialOrd for &'a str { +impl PartialOrd for &str { fn partial_cmp(&self, other: &Authority) -> Option { let left = self.as_bytes().iter().map(|b| b.to_ascii_lowercase()); let right = other.data.as_bytes().iter().map(|b| b.to_ascii_lowercase()); @@ -310,8 +310,8 @@ impl<'a> PartialOrd for &'a str { } } -impl<'a> PartialOrd<&'a str> for Authority { - fn partial_cmp(&self, other: &&'a str) -> Option { +impl PartialOrd<&str> for Authority { + fn partial_cmp(&self, other: &&str) -> Option { let left = self.data.as_bytes().iter().map(|b| b.to_ascii_lowercase()); let right = other.as_bytes().iter().map(|b| b.to_ascii_lowercase()); left.partial_cmp(right) @@ -368,10 +368,10 @@ impl Hash for Authority { } } -impl<'a> TryFrom<&'a [u8]> for Authority { +impl TryFrom<&[u8]> for Authority { type Error = InvalidUri; #[inline] - fn try_from(s: &'a [u8]) -> Result { + fn try_from(s: &[u8]) -> Result { // parse first, and only turn into Bytes if valid // Preconditon on create_authority: copy_from_slice() copies all of @@ -380,10 +380,10 @@ impl<'a> TryFrom<&'a [u8]> for Authority { } } -impl<'a> TryFrom<&'a str> for Authority { +impl TryFrom<&str> for Authority { type Error = InvalidUri; #[inline] - fn try_from(s: &'a str) -> Result { + fn try_from(s: &str) -> Result { TryFrom::try_from(s.as_bytes()) } } diff --git a/src/uri/mod.rs b/src/uri/mod.rs index 767f0743..eb7781a8 100644 --- a/src/uri/mod.rs +++ b/src/uri/mod.rs @@ -705,29 +705,29 @@ impl Uri { } } -impl<'a> TryFrom<&'a [u8]> for Uri { +impl TryFrom<&[u8]> for Uri { type Error = InvalidUri; #[inline] - fn try_from(t: &'a [u8]) -> Result { + fn try_from(t: &[u8]) -> Result { Uri::from_shared(Bytes::copy_from_slice(t)) } } -impl<'a> TryFrom<&'a str> for Uri { +impl TryFrom<&str> for Uri { type Error = InvalidUri; #[inline] - fn try_from(t: &'a str) -> Result { + fn try_from(t: &str) -> Result { t.parse() } } -impl<'a> TryFrom<&'a String> for Uri { +impl TryFrom<&String> for Uri { type Error = InvalidUri; #[inline] - fn try_from(t: &'a String) -> Result { + fn try_from(t: &String) -> Result { t.parse() } } @@ -759,11 +759,11 @@ impl TryFrom for Uri { } } -impl<'a> TryFrom<&'a Uri> for Uri { +impl TryFrom<&Uri> for Uri { type Error = crate::Error; #[inline] - fn try_from(src: &'a Uri) -> Result { + fn try_from(src: &Uri) -> Result { Ok(src.clone()) } } @@ -995,13 +995,13 @@ impl PartialEq for str { } } -impl<'a> PartialEq<&'a str> for Uri { - fn eq(&self, other: &&'a str) -> bool { +impl PartialEq<&str> for Uri { + fn eq(&self, other: &&str) -> bool { self == *other } } -impl<'a> PartialEq for &'a str { +impl PartialEq for &str { fn eq(&self, uri: &Uri) -> bool { uri == *self } diff --git a/src/uri/path.rs b/src/uri/path.rs index 8f9356e8..058aae07 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -304,18 +304,18 @@ impl PathAndQuery { } } -impl<'a> TryFrom<&'a [u8]> for PathAndQuery { +impl TryFrom<&[u8]> for PathAndQuery { type Error = InvalidUri; #[inline] - fn try_from(s: &'a [u8]) -> Result { + fn try_from(s: &[u8]) -> Result { PathAndQuery::from_shared(Bytes::copy_from_slice(s)) } } -impl<'a> TryFrom<&'a str> for PathAndQuery { +impl TryFrom<&str> for PathAndQuery { type Error = InvalidUri; #[inline] - fn try_from(s: &'a str) -> Result { + fn try_from(s: &str) -> Result { TryFrom::try_from(s.as_bytes()) } } @@ -395,16 +395,16 @@ impl PartialEq for PathAndQuery { } } -impl<'a> PartialEq for &'a str { +impl PartialEq for &str { #[inline] fn eq(&self, other: &PathAndQuery) -> bool { self == &other.as_str() } } -impl<'a> PartialEq<&'a str> for PathAndQuery { +impl PartialEq<&str> for PathAndQuery { #[inline] - fn eq(&self, other: &&'a str) -> bool { + fn eq(&self, other: &&str) -> bool { self.as_str() == *other } } @@ -451,14 +451,14 @@ impl PartialOrd for str { } } -impl<'a> PartialOrd<&'a str> for PathAndQuery { +impl PartialOrd<&str> for PathAndQuery { #[inline] - fn partial_cmp(&self, other: &&'a str) -> Option { + fn partial_cmp(&self, other: &&str) -> Option { self.as_str().partial_cmp(*other) } } -impl<'a> PartialOrd for &'a str { +impl PartialOrd for &str { #[inline] fn partial_cmp(&self, other: &PathAndQuery) -> Option { self.partial_cmp(&other.as_str()) diff --git a/src/uri/scheme.rs b/src/uri/scheme.rs index dbcc8c3f..bf4d59c3 100644 --- a/src/uri/scheme.rs +++ b/src/uri/scheme.rs @@ -67,10 +67,10 @@ impl Scheme { } } -impl<'a> TryFrom<&'a [u8]> for Scheme { +impl TryFrom<&[u8]> for Scheme { type Error = InvalidUri; #[inline] - fn try_from(s: &'a [u8]) -> Result { + fn try_from(s: &[u8]) -> Result { use self::Scheme2::*; match Scheme2::parse_exact(s)? { @@ -89,10 +89,10 @@ impl<'a> TryFrom<&'a [u8]> for Scheme { } } -impl<'a> TryFrom<&'a str> for Scheme { +impl TryFrom<&str> for Scheme { type Error = InvalidUri; #[inline] - fn try_from(s: &'a str) -> Result { + fn try_from(s: &str) -> Result { TryFrom::try_from(s.as_bytes()) } } From 60fbf319500def124cabb21c8fe8533cf209ce58 Mon Sep 17 00:00:00 2001 From: tottoto Date: Sun, 15 Feb 2026 21:34:05 +0900 Subject: [PATCH 32/52] chore(ci): update to actions/checkout@v6 (#819) --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b25d696..fde720f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -55,7 +55,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust (${{ matrix.rust }}) uses: dtolnay/rust-toolchain@master @@ -74,7 +74,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Get MSRV from package metadata id: metadata @@ -99,7 +99,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -112,7 +112,7 @@ jobs: minimal-versions: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-hack @@ -125,7 +125,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@nightly @@ -139,7 +139,7 @@ jobs: name: semver runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Check semver uses: obi1kenobi/cargo-semver-checks-action@v2 with: From ed680c4d90a514b7f427efc99b61e60632811d2f Mon Sep 17 00:00:00 2001 From: tottoto Date: Sat, 28 Feb 2026 00:21:53 +0900 Subject: [PATCH 33/52] tests: update to rand 0.10 (#818) --- .github/workflows/ci.yml | 13 ++++++++++--- Cargo.toml | 2 +- tests/header_map_fuzz.rs | 24 ++++++++++++------------ 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde720f7..3817cf8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,16 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + + - uses: taiki-e/install-action@cargo-hack + + - name: Remove dev-dependencies + run: cargo hack --remove-dev-deps update + + - name: Pin deps + run: cargo update -p itoa --precise 1.0.15 + - name: Get MSRV from package metadata id: metadata run: echo "msrv=$(yq '.package.rust-version' Cargo.toml)" >> $GITHUB_OUTPUT @@ -85,9 +95,6 @@ jobs: with: toolchain: ${{ steps.metadata.outputs.msrv }} - - name: Pin deps - run: cargo update -p itoa --precise 1.0.15 - - name: Test run: cargo check -p http diff --git a/Cargo.toml b/Cargo.toml index 1c8906f0..990720ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ itoa = "1" [dev-dependencies] quickcheck = "1" -rand = "0.8.0" +rand = "0.10" serde = "1.0" serde_json = "1.0" doc-comment = "0.3" diff --git a/tests/header_map_fuzz.rs b/tests/header_map_fuzz.rs index 40db0494..14f172d0 100644 --- a/tests/header_map_fuzz.rs +++ b/tests/header_map_fuzz.rs @@ -3,8 +3,8 @@ use http::*; use quickcheck::{Arbitrary, Gen, QuickCheck, TestResult}; use rand::rngs::StdRng; -use rand::seq::SliceRandom; -use rand::{Rng, SeedableRng}; +use rand::seq::IndexedRandom; +use rand::{RngExt, SeedableRng}; use std::collections::HashMap; @@ -76,12 +76,12 @@ impl Fuzz { let mut steps = vec![]; let mut expect = AltMap::default(); - let num = rng.gen_range(5..500); + let num = rng.random_range(5..500); let weight = Weight { - insert: rng.gen_range(1..10), - remove: rng.gen_range(1..10), - append: rng.gen_range(1..10), + insert: rng.random_range(1..10), + remove: rng.random_range(1..10), + append: rng.random_range(1..10), }; while steps.len() < num { @@ -111,8 +111,8 @@ impl Fuzz { } impl Arbitrary for Fuzz { - fn arbitrary(_: &mut Gen) -> Self { - Self::new(rand::thread_rng().gen()) + fn arbitrary(g: &mut Gen) -> Self { + Self::new(Arbitrary::arbitrary(g)) } } @@ -130,7 +130,7 @@ impl AltMap { fn gen_action(&mut self, weight: &Weight, rng: &mut StdRng) -> Action { let sum = weight.insert + weight.remove + weight.append; - let mut num = rng.gen_range(0..sum); + let mut num = rng.random_range(0..sum); if num < weight.insert { return self.gen_insert(rng); @@ -180,7 +180,7 @@ impl AltMap { /// Negative numbers weigh finding an existing header higher fn gen_name(&self, weight: i32, rng: &mut StdRng) -> HeaderName { - let mut existing = rng.gen_ratio(1, weight.abs() as u32); + let mut existing = rng.random_ratio(1, weight.abs() as u32); if weight < 0 { existing = !existing; @@ -202,7 +202,7 @@ impl AltMap { if self.map.is_empty() { None } else { - let n = rng.gen_range(0..self.map.len()); + let n = rng.random_range(0..self.map.len()); self.map.keys().nth(n).map(Clone::clone) } } @@ -337,7 +337,7 @@ fn gen_header_name(g: &mut StdRng) -> HeaderName { header::X_XSS_PROTECTION, ]; - if g.gen_ratio(1, 2) { + if g.random_ratio(1, 2) { STANDARD_HEADERS.choose(g).unwrap().clone() } else { let value = gen_string(g, 1, 25); From d59d939f928c6d836f5c87940f01399cb45cddb9 Mon Sep 17 00:00:00 2001 From: AurelienFT <32803821+AurelienFT@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:16:15 +0100 Subject: [PATCH 34/52] refactor: Remove usage of float instruction (#823) --- src/header/map.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index 85504d14..a123606e 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -371,7 +371,7 @@ const FORWARD_SHIFT_THRESHOLD: usize = 512; // If growing the hash map would cause the load factor to drop bellow this // threshold, then instead of growing, the headermap is switched to the red // danger state and safe hashing is used instead. -const LOAD_FACTOR_THRESHOLD: f32 = 0.2; +const LOAD_FACTOR_THRESHOLD: usize = 5; // Macro used to iterate the hash table starting at a given point, looping when // the end is hit. @@ -1739,9 +1739,10 @@ impl HeaderMap { let len = self.entries.len(); if self.danger.is_yellow() { - let load_factor = self.entries.len() as f32 / self.indices.len() as f32; - - if load_factor >= LOAD_FACTOR_THRESHOLD { + // Overflow is not a concern here: entries.len() is bounded by + // MAX_SIZE (2^15) and LOAD_FACTOR_THRESHOLD is 5, so the product + // fits comfortably within a usize. + if self.entries.len() * LOAD_FACTOR_THRESHOLD >= self.indices.len() { // Transition back to green danger level self.danger.set_green(); From 1ad200ec4ce5ec714005d500f8b0cea39c6c16f5 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 25 Mar 2026 17:10:34 -0400 Subject: [PATCH 35/52] refactor(uri): consolidate PathAndQuery::from_shared and from_static (#825) Previously, the constructors had their logic nearly duplicated, one to fit the const fn context, with minor differences. This change consolidates it back into a single const fn, with the non-const parts separated out, so that the parsing logic does not drift unintentionally. --- src/uri/path.rs | 237 ++++++++++++++++++++---------------------------- 1 file changed, 98 insertions(+), 139 deletions(-) diff --git a/src/uri/path.rs b/src/uri/path.rs index 058aae07..4c0bbbe7 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -7,14 +7,6 @@ use bytes::Bytes; use super::{ErrorKind, InvalidUri}; use crate::byte_str::ByteStr; -/// Validation result for path and query parsing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PathAndQueryError { - InvalidPathChar, - InvalidQueryChar, - FragmentNotAllowed, -} - /// Represents the path component of a URI #[derive(Clone)] pub struct PathAndQuery { @@ -27,95 +19,14 @@ const NONE: u16 = u16::MAX; impl PathAndQuery { // Not public while `bytes` is unstable. pub(super) fn from_shared(mut src: Bytes) -> Result { - let mut query = NONE; - let mut fragment = None; - - let mut is_maybe_not_utf8 = false; - - // block for iterator borrow - { - let mut iter = src.as_ref().iter().enumerate(); - - // path ... - for (i, &b) in &mut iter { - // See https://url.spec.whatwg.org/#path-state - match b { - b'?' => { - debug_assert_eq!(query, NONE); - query = i as u16; - break; - } - b'#' => { - fragment = Some(i); - break; - } - - // This is the range of bytes that don't need to be - // percent-encoded in the path. If it should have been - // percent-encoded, then error. - #[rustfmt::skip] - 0x21 | - 0x24..=0x3B | - 0x3D | - 0x40..=0x5F | - 0x61..=0x7A | - 0x7C | - 0x7E => {} - - // potentially utf8, might not, should check - 0x7F..=0xFF => { - is_maybe_not_utf8 = true; - } - - // These are code points that are supposed to be - // percent-encoded in the path but there are clients - // out there sending them as is and httparse accepts - // to parse those requests, so they are allowed here - // for parity. - // - // For reference, those are code points that are used - // to send requests with JSON directly embedded in - // the URI path. Yes, those things happen for real. - #[rustfmt::skip] - b'"' | - b'{' | b'}' => {} - - _ => return Err(ErrorKind::InvalidUriChar.into()), - } - } - - // query ... - if query != NONE { - for (i, &b) in iter { - match b { - // While queries *should* be percent-encoded, most - // bytes are actually allowed... - // See https://url.spec.whatwg.org/#query-state - // - // Allowed: 0x21 / 0x24 - 0x3B / 0x3D / 0x3F - 0x7E - #[rustfmt::skip] - 0x21 | - 0x24..=0x3B | - 0x3D | - 0x3F..=0x7E => {} - - 0x7F..=0xFF => { - is_maybe_not_utf8 = true; - } - - b'#' => { - fragment = Some(i); - break; - } - - _ => return Err(ErrorKind::InvalidUriChar.into()), - } - } - } - } + let Scanned { + query, + fragment, + is_maybe_not_utf8, + } = scan_path_and_query(&src)?; if let Some(i) = fragment { - src.truncate(i); + src.truncate(i as usize); } let data = if is_maybe_not_utf8 { @@ -147,12 +58,17 @@ impl PathAndQuery { /// ``` #[inline] pub const fn from_static(src: &'static str) -> Self { - match validate_path_and_query_bytes(src.as_bytes()) { - Ok(query) => PathAndQuery { + match scan_path_and_query(src.as_bytes()) { + Ok(Scanned { + query, + fragment: None, + is_maybe_not_utf8: false, + }) => PathAndQuery { data: ByteStr::from_static(src), query, }, - Err(_) => panic!("static str is not valid path"), + // Yes, we reject fragments and non-utf8 + _ => panic!("static str is not valid path"), } } @@ -479,37 +395,68 @@ impl PartialOrd for String { } } -/// Shared validation logic for path and query bytes. -/// Returns the query position (or NONE), or an error. -const fn validate_path_and_query_bytes(bytes: &[u8]) -> Result { - let mut query: u16 = NONE; - let mut i: usize = 0; +// Scanner implementation that is `const fn`, usable by both `from_static` +// and `from_shared`. +// ===== + +struct Scanned { + query: u16, + fragment: Option, + is_maybe_not_utf8: bool, +} + +const fn scan_path_and_query(bytes: &[u8]) -> Result { + let mut i = 0; + let mut query = NONE; + let mut fragment = None; + + let mut is_maybe_not_utf8 = false; - // path ... while i < bytes.len() { - let b = bytes[i]; - if b == b'?' { - query = i as u16; - i += 1; - break; - } else if b == b'#' { - return Err(PathAndQueryError::FragmentNotAllowed); - } else { - let allowed = b == 0x21 - || (b >= 0x24 && b <= 0x3B) - || b == 0x3D - || (b >= 0x40 && b <= 0x5F) - || (b >= 0x61 && b <= 0x7A) - || b == 0x7C - || b == 0x7E - || b == b'"' - || b == b'{' - || b == b'}' - || (b >= 0x7F); - - if !allowed { - return Err(PathAndQueryError::InvalidPathChar); + // See https://url.spec.whatwg.org/#path-state + match bytes[i] { + b'?' => { + debug_assert!(query == NONE); + query = i as u16; + i += 1; + break; + } + b'#' => { + fragment = Some(i as u16); + break; + } + + // This is the range of bytes that don't need to be + // percent-encoded in the path. If it should have been + // percent-encoded, then error. + #[rustfmt::skip] + 0x21 | + 0x24..=0x3B | + 0x3D | + 0x40..=0x5F | + 0x61..=0x7A | + 0x7C | + 0x7E => {} + + // potentially utf8, might not, should check + 0x7F..=0xFF => { + is_maybe_not_utf8 = true; } + + // These are code points that are supposed to be + // percent-encoded in the path but there are clients + // out there sending them as is and httparse accepts + // to parse those requests, so they are allowed here + // for parity. + // + // For reference, those are code points that are used + // to send requests with JSON directly embedded in + // the URI path. Yes, those things happen for real. + #[rustfmt::skip] + b'"' | + b'{' | b'}' => {} + + _ => return Err(ErrorKind::InvalidUriChar), } i += 1; } @@ -517,26 +464,38 @@ const fn validate_path_and_query_bytes(bytes: &[u8]) -> Result {} + + 0x7F..=0xFF => { + is_maybe_not_utf8 = true; + } - let allowed = b == 0x21 - || (b >= 0x24 && b <= 0x3B) - || b == 0x3D - || (b >= 0x3F && b <= 0x7E) - || (b >= 0x7F); + b'#' => { + fragment = Some(i as u16); + break; + } - if !allowed { - return Err(PathAndQueryError::InvalidQueryChar); + _ => return Err(ErrorKind::InvalidUriChar), } - i += 1; } } - Ok(query) + Ok(Scanned { + query, + fragment, + is_maybe_not_utf8, + }) } #[cfg(test)] From ae48fb55b090b4859d38a3a49a8332b83492d7c1 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 30 Mar 2026 11:17:22 -0400 Subject: [PATCH 36/52] fix(uri): reject Path::from_shared/from_static if doesn't start with slash (#826) Closes #507 --- src/uri/mod.rs | 11 ++++++++++- src/uri/path.rs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/uri/mod.rs b/src/uri/mod.rs index eb7781a8..16b45c84 100644 --- a/src/uri/mod.rs +++ b/src/uri/mod.rs @@ -135,6 +135,7 @@ enum ErrorKind { SchemeMissing, AuthorityMissing, PathAndQueryMissing, + PathDoesNotStartWithSlash, TooLong, Empty, SchemeTooLong, @@ -872,10 +873,17 @@ fn parse_full(mut s: Bytes) -> Result { data: unsafe { ByteStr::from_utf8_unchecked(authority) }, }; + // When absolute, path is coered to / if empty. + let path_and_query = if s.is_empty() { + PathAndQuery::slash() + } else { + PathAndQuery::from_shared(s)? + }; + Ok(Uri { scheme: scheme.into(), authority, - path_and_query: PathAndQuery::from_shared(s)?, + path_and_query, }) } @@ -1070,6 +1078,7 @@ impl InvalidUri { ErrorKind::SchemeMissing => "scheme missing", ErrorKind::AuthorityMissing => "authority missing", ErrorKind::PathAndQueryMissing => "path missing", + ErrorKind::PathDoesNotStartWithSlash => "path does not start with slash", ErrorKind::TooLong => "uri too long", ErrorKind::Empty => "empty string", ErrorKind::SchemeTooLong => "scheme too long", diff --git a/src/uri/path.rs b/src/uri/path.rs index 4c0bbbe7..dfbb2e95 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -412,6 +412,14 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { let mut is_maybe_not_utf8 = false; + if bytes.is_empty() { + return Err(ErrorKind::Empty); + } + + if !matches!(bytes[0], b'/' | b'?' | b'#') { + return Err(ErrorKind::PathDoesNotStartWithSlash); + } + while i < bytes.len() { // See https://url.spec.whatwg.org/#path-state match bytes[i] { @@ -621,6 +629,16 @@ mod tests { PathAndQuery::try_from(&[b'/', b'a', b'?', 0xFF][..]).expect_err("reject invalid utf8"); } + #[test] + fn rejects_empty_string() { + PathAndQuery::try_from("").expect_err("reject empty str"); + } + + #[test] + fn requires_starting_with_slash() { + PathAndQuery::try_from("sneaky").expect_err("reject missing slash"); + } + #[test] fn json_is_fine() { assert_eq!( From 29dd307b3e382a4343fc917fa3c41125ac50dfb8 Mon Sep 17 00:00:00 2001 From: daalfox <123469030+daalfox@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:06:34 +0500 Subject: [PATCH 37/52] docs(extensions): rephrase internal comment (#827) Rephrase the comment on `map` field in `Extensions` to make it easier to understand --- src/extensions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions.rs b/src/extensions.rs index ed6f85c2..f1c1b7c1 100644 --- a/src/extensions.rs +++ b/src/extensions.rs @@ -33,8 +33,8 @@ impl Hasher for IdHasher { /// extra data derived from the underlying protocol. #[derive(Clone, Default)] pub struct Extensions { - // If extensions are never used, no need to carry around an empty HashMap. - // That's 3 words. Instead, this is only 1 word. + // Extensions might never be used and carrying an empty HashMap around is + // inefficient (because it's 3 words). This is only 1 word instead. map: Option>, } From 68e0abb052a243a5530ad4c404cb0b169a7ecb4a Mon Sep 17 00:00:00 2001 From: Vladislav Maltsev <117042535+vleksis@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:03:52 +0300 Subject: [PATCH 38/52] docs: fix typo in request builder docs (#831) --- src/request.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/request.rs b/src/request.rs index 0eb36b7d..7cf19fa5 100644 --- a/src/request.rs +++ b/src/request.rs @@ -180,7 +180,7 @@ pub struct Parts { /// An HTTP request builder /// -/// This type can be used to construct an instance or `Request` +/// This type can be used to construct an instance of `Request` /// through a builder-like pattern. #[derive(Debug)] pub struct Builder { From 6e2dd42a15d4c1711baa2191bd1d15022e1e2e9c Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 13 May 2026 11:46:20 -0700 Subject: [PATCH 39/52] fix: clamp Extend size hint so HeaderMap reserve cannot overflow (#833) --- src/header/map.rs | 12 ++++++++++-- tests/header_map.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index a123606e..de8bd2d2 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -2133,12 +2133,16 @@ impl Extend<(Option, T)> for HeaderMap { // Reserve the entire hint lower bound if the map is empty. // Otherwise reserve half the hint (rounded up), so the map // will only resize twice in the worst case. - let reserve = if self.is_empty() { + let hint = if self.is_empty() { iter.size_hint().0 } else { (iter.size_hint().0 + 1) / 2 }; + // Clamp the hint so an over-estimate cannot overflow `reserve`. + let max_reserve = usable_capacity(MAX_SIZE).saturating_sub(self.entries.len()); + let reserve = hint.min(max_reserve); + self.reserve(reserve); // The structure of this is a bit weird, but it is mostly to make the @@ -2189,12 +2193,16 @@ impl Extend<(HeaderName, T)> for HeaderMap { // will only resize twice in the worst case. let iter = iter.into_iter(); - let reserve = if self.is_empty() { + let hint = if self.is_empty() { iter.size_hint().0 } else { (iter.size_hint().0 + 1) / 2 }; + // Clamp the hint so an over-estimate cannot overflow `reserve`. + let max_reserve = usable_capacity(MAX_SIZE).saturating_sub(self.entries.len()); + let reserve = hint.min(max_reserve); + self.reserve(reserve); for (k, v) in iter { diff --git a/tests/header_map.rs b/tests/header_map.rs index 9a9d7e12..8f407fd9 100644 --- a/tests/header_map.rs +++ b/tests/header_map.rs @@ -55,6 +55,23 @@ fn with_capacity_overflow() { HeaderMap::::with_capacity(24_577); } +#[test] +fn extend_size_hint_above_capacity() { + // A `HeaderMap` may hold more values than the table can index when many + // values are appended under one name, so an exact size hint can exceed the + // largest `reserve` request. Extending must not panic in that case. + let name = HeaderName::from_static("h"); + let value = HeaderValue::from_static("0"); + let pairs: Vec<(HeaderName, HeaderValue)> = + std::iter::repeat_with(|| (name.clone(), value.clone())) + .take(24_577) + .collect(); + + let map = HeaderMap::from_iter(pairs); + assert_eq!(map.len(), 24_577); + assert_eq!(map.keys_len(), 1); +} + #[test] #[should_panic] fn reserve_overflow() { From 1b968dc519c49b1922bc546c95f33900e684f4ab Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 25 May 2026 15:08:55 -0400 Subject: [PATCH 40/52] fix(header): fix stacked borrows for IterMut/ValuesIterMut (#837) --- src/header/map.rs | 109 +++++++++++++++++++++++++++++++++----------- tests/header_map.rs | 34 ++++++++++++++ 2 files changed, 116 insertions(+), 27 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index de8bd2d2..e3eb24bb 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -129,7 +129,13 @@ pub struct Iter<'a, T> { /// yielded more than once if it has more than one associated value. #[derive(Debug)] pub struct IterMut<'a, T> { - map: *mut HeaderMap, + // Raw access avoids reborrowing the whole `HeaderMap` on every `next()`, + // which would invalidate previously yielded `&mut T`s. + entries: *mut Bucket, + entries_len: usize, + // This points at the original `HeaderMap::extra_values` allocation for the + // lifetime of the iterator. + extra_values: *mut ExtraValue, entry: usize, cursor: Option, lt: PhantomData<&'a mut HeaderMap>, @@ -234,7 +240,11 @@ pub struct ValueIter<'a, T> { /// A mutable iterator of all values associated with a single header name. #[derive(Debug)] pub struct ValueIterMut<'a, T> { - map: *mut HeaderMap, + // Raw access avoids reborrowing the whole `HeaderMap` on every step. + entries: *mut Bucket, + // This points at the original `HeaderMap::extra_values` allocation for the + // lifetime of the iterator. + extra_values: *mut ExtraValue, index: usize, front: Option, back: Option, @@ -951,7 +961,9 @@ impl HeaderMap { /// ``` pub fn iter_mut(&mut self) -> IterMut<'_, T> { IterMut { - map: self as *mut _, + entries: self.entries.as_mut_ptr(), + entries_len: self.entries.len(), + extra_values: self.extra_values.as_mut_ptr(), entry: 0, cursor: self.entries.first().map(|_| Cursor::Head), lt: PhantomData, @@ -1129,7 +1141,8 @@ impl HeaderMap { }; ValueIterMut { - map: self as *mut _, + entries: self.entries.as_mut_ptr(), + extra_values: self.extra_values.as_mut_ptr(), index: idx, front: Some(Head), back: Some(back), @@ -2363,11 +2376,11 @@ unsafe impl<'a, T: Sync> Send for Iter<'a, T> {} // ===== impl IterMut ===== impl<'a, T> IterMut<'a, T> { - fn next_unsafe(&mut self) -> Option<(&'a HeaderName, *mut T)> { + fn next_unsafe(&mut self) -> Option<(*const HeaderName, *mut T)> { use self::Cursor::*; if self.cursor.is_none() { - if (self.entry + 1) >= unsafe { &*self.map }.entries.len() { + if (self.entry + 1) >= self.entries_len { return None; } @@ -2375,22 +2388,46 @@ impl<'a, T> IterMut<'a, T> { self.cursor = Some(Cursor::Head); } - let entry = &mut unsafe { &mut *self.map }.entries[self.entry]; + // SAFETY: `self.entry < self.entries_len`, and the iterator has + // exclusive access to the underlying map for `'a`, so the `entries` + // allocation remains valid for the lifetime of the iterator. + let entry = unsafe { self.entries.add(self.entry) }; match self.cursor.unwrap() { Head => { - self.cursor = entry.links.map(|l| Values(l.next)); - Some((&entry.key, &mut entry.value as *mut _)) + // SAFETY: `entry` points at a live bucket in `entries`. + self.cursor = unsafe { (*entry).links }.map(|l| Values(l.next)); + // SAFETY: `entry` points at a live bucket, and the iterator only + // yields each slot at most once, so materializing these field + // pointers does not alias another yielded `&mut T`. + Some(unsafe { + ( + ptr::addr_of!((*entry).key), + ptr::addr_of_mut!((*entry).value), + ) + }) } Values(idx) => { - let extra = &mut unsafe { &mut (*self.map) }.extra_values[idx]; + // SAFETY: `idx` comes from the `links` chain stored in a live + // bucket / extra value, so it points at a live `extra_values` + // slot for the duration of iteration. + let extra = unsafe { self.extra_values.add(idx) }; - match extra.next { + // SAFETY: `extra` points at a live extra value. + match unsafe { (*extra).next } { Link::Entry(_) => self.cursor = None, Link::Extra(i) => self.cursor = Some(Values(i)), } - Some((&entry.key, &mut extra.value as *mut _)) + // SAFETY: `entry` and `extra` both point at live elements in the + // map backing storage, and the iterator only yields each value + // slot at most once. + Some(unsafe { + ( + ptr::addr_of!((*entry).key), + ptr::addr_of_mut!((*extra).value), + ) + }) } } } @@ -2401,14 +2438,13 @@ impl<'a, T> Iterator for IterMut<'a, T> { fn next(&mut self) -> Option { self.next_unsafe() - .map(|(key, ptr)| (key, unsafe { &mut *ptr })) + .map(|(key, ptr)| (unsafe { &*key }, unsafe { &mut *ptr })) } fn size_hint(&self) -> (usize, Option) { - let map = unsafe { &*self.map }; - debug_assert!(map.entries.len() >= self.entry); + debug_assert!(self.entries_len >= self.entry); - let lower = map.entries.len() - self.entry; + let lower = self.entries_len - self.entry; // We could pessimistically guess at the upper bound, saying // that its lower + map.extra_values.len(). That could be // way over though, such as if we're near the end, and have @@ -3023,7 +3059,9 @@ impl<'a, T: 'a> Iterator for ValueIterMut<'a, T> { fn next(&mut self) -> Option { use self::Cursor::*; - let entry = &mut unsafe { &mut *self.map }.entries[self.index]; + // SAFETY: `self.index` was created from a live occupied entry and stays + // fixed for the lifetime of this iterator. + let entry = unsafe { self.entries.add(self.index) }; match self.front { Some(Head) => { @@ -3032,7 +3070,8 @@ impl<'a, T: 'a> Iterator for ValueIterMut<'a, T> { self.back = None; } else { // Update the iterator state - match entry.links { + // SAFETY: `entry` points at a live bucket in `entries`. + match unsafe { (*entry).links } { Some(links) => { self.front = Some(Values(links.next)); } @@ -3040,22 +3079,29 @@ impl<'a, T: 'a> Iterator for ValueIterMut<'a, T> { } } - Some(&mut entry.value) + // SAFETY: `entry` points at a live bucket, and `front`/`back` + // ensure this value slot is yielded at most once. + Some(unsafe { &mut *ptr::addr_of_mut!((*entry).value) }) } Some(Values(idx)) => { - let extra = &mut unsafe { &mut *self.map }.extra_values[idx]; + // SAFETY: `idx` comes from the live linked list rooted at + // `self.index`, so it refers to a live extra value slot. + let extra = unsafe { self.extra_values.add(idx) }; if self.front == self.back { self.front = None; self.back = None; } else { - match extra.next { + // SAFETY: `extra` points at a live extra value. + match unsafe { (*extra).next } { Link::Entry(_) => self.front = None, Link::Extra(i) => self.front = Some(Values(i)), } } - Some(&mut extra.value) + // SAFETY: `extra` points at a live extra value, and + // `front`/`back` ensure this value slot is yielded at most once. + Some(unsafe { &mut *ptr::addr_of_mut!((*extra).value) }) } None => None, } @@ -3066,28 +3112,37 @@ impl<'a, T: 'a> DoubleEndedIterator for ValueIterMut<'a, T> { fn next_back(&mut self) -> Option { use self::Cursor::*; - let entry = &mut unsafe { &mut *self.map }.entries[self.index]; + // SAFETY: `self.index` was created from a live occupied entry and stays + // fixed for the lifetime of this iterator. + let entry = unsafe { self.entries.add(self.index) }; match self.back { Some(Head) => { self.front = None; self.back = None; - Some(&mut entry.value) + // SAFETY: `entry` points at a live bucket, and `front`/`back` + // ensure this value slot is yielded at most once. + Some(unsafe { &mut *ptr::addr_of_mut!((*entry).value) }) } Some(Values(idx)) => { - let extra = &mut unsafe { &mut *self.map }.extra_values[idx]; + // SAFETY: `idx` comes from the live linked list rooted at + // `self.index`, so it refers to a live extra value slot. + let extra = unsafe { self.extra_values.add(idx) }; if self.front == self.back { self.front = None; self.back = None; } else { - match extra.prev { + // SAFETY: `extra` points at a live extra value. + match unsafe { (*extra).prev } { Link::Entry(_) => self.back = Some(Head), Link::Extra(idx) => self.back = Some(Values(idx)), } } - Some(&mut extra.value) + // SAFETY: `extra` points at a live extra value, and + // `front`/`back` ensure this value slot is yielded at most once. + Some(unsafe { &mut *ptr::addr_of_mut!((*extra).value) }) } None => None, } diff --git a/tests/header_map.rs b/tests/header_map.rs index 8f407fd9..a6a5023c 100644 --- a/tests/header_map.rs +++ b/tests/header_map.rs @@ -689,3 +689,37 @@ fn ensure_miri_sharedreadonly_not_violated() { let _foo = &headers.iter().next(); } + +#[test] +fn ensure_miri_itermut_not_violated() { + let mut headers = HeaderMap::::default(); + headers.insert(HeaderName::from_static("hello"), 1u32); + headers.insert(HeaderName::from_static("zomg"), 2u32); + + let mut iter = headers.iter_mut(); + let (_, first) = iter.next().unwrap(); + let (_, second) = iter.next().unwrap(); + + *first += 10; + *second += 20; +} + +#[test] +fn ensure_miri_valueitermut_not_violated() { + let mut headers = HeaderMap::::default(); + headers.insert(HeaderName::from_static("hello"), 1u32); + headers.append(HeaderName::from_static("hello"), 2u32); + headers.append(HeaderName::from_static("hello"), 3u32); + + let mut entry = match headers.entry(HeaderName::from_static("hello")) { + Entry::Occupied(entry) => entry, + Entry::Vacant(_) => panic!(), + }; + + let mut iter = entry.iter_mut(); + let first = iter.next().unwrap(); + let second = iter.next().unwrap(); + + *first += 10; + *second += 20; +} From bc3b0441be3065fc2653e9b3b1392c0fed873482 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 25 May 2026 15:21:23 -0400 Subject: [PATCH 41/52] fix(header): use a set_len guard in IntoIter drop (#838) --- src/header/map.rs | 17 +++++++++++----- tests/header_map.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/header/map.rs b/src/header/map.rs index e3eb24bb..58fbaace 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -3195,13 +3195,20 @@ impl FusedIterator for IntoIter {} impl Drop for IntoIter { fn drop(&mut self) { - // Ensure the iterator is consumed - for _ in self.by_ref() {} + struct Guard<'a, T>(&'a mut IntoIter); - // All the values have already been yielded out. - unsafe { - self.extra_values.set_len(0); + impl<'a, T> Drop for Guard<'a, T> { + fn drop(&mut self) { + unsafe { + self.0.extra_values.set_len(0); + } + } } + + let guard = Guard(self); + + // Ensure the iterator is consumed + for _ in guard.0.by_ref() {} } } diff --git a/tests/header_map.rs b/tests/header_map.rs index a6a5023c..a7a75592 100644 --- a/tests/header_map.rs +++ b/tests/header_map.rs @@ -723,3 +723,52 @@ fn ensure_miri_valueitermut_not_violated() { *first += 10; *second += 20; } + +#[test] +fn into_iter_drop_panic_after_yielding_extra_value_double_drops() { + use std::panic::{catch_unwind, AssertUnwindSafe}; + + struct ManuallyAllocated { + ptr: *mut u8, + panic_on_drop: bool, + } + + impl ManuallyAllocated { + fn new(byte: u8, panic_on_drop: bool) -> Self { + Self { + ptr: Box::into_raw(Box::new(byte)), + panic_on_drop, + } + } + } + + impl Drop for ManuallyAllocated { + fn drop(&mut self) { + unsafe { + drop(Box::from_raw(self.ptr)); + } + + if self.panic_on_drop { + panic!("intentional drop panic"); + } + } + } + + let mut map: HeaderMap = HeaderMap::default(); + map.append("x-first", ManuallyAllocated::new(1, false)); + map.append("x-first", ManuallyAllocated::new(2, false)); + map.insert("x-second", ManuallyAllocated::new(3, true)); + + let mut iter = map.into_iter(); + + // HeaderMap::IntoIter yields extra values with ptr::read from + // self.extra_values and relies on Drop setting self.extra_values.len() to + // zero after the iterator has been fully consumed. If a later value's Drop + // panics while IntoIter::drop is draining the iterator, that set_len(0) is + // skipped. The Vec then drops already-yielded extra value slots again. The + // safe sequence below therefore double-frees byte 2 under Miri. + drop(iter.next().unwrap()); + drop(iter.next().unwrap()); + + let _ = catch_unwind(AssertUnwindSafe(|| drop(iter))); +} From a24c968ba3b53c4c9953164235664cab9e8fa315 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 25 May 2026 15:26:36 -0400 Subject: [PATCH 42/52] v1.4.1 --- CHANGELOG.md | 7 +++++++ Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f393c3..211a8301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.4.1 (May 25, 2026) + +- Fix `PathAndQuery::from_static()` and `from_shared()` to reject inputs that do not start with `/`. +- Fix `Extend` for `HeaderMap` to clamp max size hint and not overflow. +- Fix `header::IntoIter` that could use-after-free if the generic value type could panic on drop. +- Fix `header::{IterMut, ValuesIterMut}` to not violate stacked borrows. + # 1.4.0 (November 24, 2025) - Add `StatusCode::EARLY_HINTS` constant for 103 Early Hints. diff --git a/Cargo.toml b/Cargo.toml index 990720ad..478a144b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "http" # - Update html_root_url in lib.rs. # - Update CHANGELOG.md. # - Create git tag -version = "1.4.0" +version = "1.4.1" readme = "README.md" documentation = "https://docs.rs/http" repository = "https://github.com/hyperium/http" From ec3f8ce1bb571223d5e738c6dd7a749670f821dc Mon Sep 17 00:00:00 2001 From: Milo Mirate <992859+mmirate@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:50:18 -0400 Subject: [PATCH 43/52] feat(method): impl PartialOrd + Ord (#840) --- src/method.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/method.rs b/src/method.rs index 3f2c6bcb..fc535d4f 100644 --- a/src/method.rs +++ b/src/method.rs @@ -187,6 +187,20 @@ impl AsRef for Method { } } +impl Ord for Method { + #[inline] + fn cmp(&self, other: &Method) -> std::cmp::Ordering { + self.as_ref().cmp(other.as_ref()) + } +} + +impl PartialOrd for Method { + #[inline] + fn partial_cmp(&self, other: &Method) -> Option { + Some(self.cmp(other)) + } +} + impl PartialEq<&Method> for Method { #[inline] fn eq(&self, other: &&Method) -> bool { From df75ca3ffe2821df665aa0962d62312691942b11 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 8 Jun 2026 09:23:52 -0400 Subject: [PATCH 44/52] fix(uri): allow STAR paths with scheme/auth (#843) --- src/uri/builder.rs | 14 ++++++++++++++ src/uri/path.rs | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/uri/builder.rs b/src/uri/builder.rs index d5f7f49b..06c7ed6c 100644 --- a/src/uri/builder.rs +++ b/src/uri/builder.rs @@ -208,4 +208,18 @@ mod tests { let uri = Builder::from(original_uri.clone()).build().unwrap(); assert_eq!(original_uri, uri); } + + #[test] + fn build_star_for_http2() { + let uri = Builder::new() + .scheme("https") + .authority("example.com") + .path_and_query("*") + .build() + .unwrap(); + + assert_eq!(uri.scheme(), Some(&Scheme::HTTPS)); + assert_eq!(uri.host(), Some("example.com")); + assert_eq!(uri.path(), "*"); + } } diff --git a/src/uri/path.rs b/src/uri/path.rs index dfbb2e95..1c7e18c6 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -416,6 +416,14 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { return Err(ErrorKind::Empty); } + if bytes.len() == 1 && bytes[0] == b'*' { + return Ok(Scanned { + query, + fragment, + is_maybe_not_utf8: false, + }); + } + if !matches!(bytes[0], b'/' | b'?' | b'#') { return Err(ErrorKind::PathDoesNotStartWithSlash); } From a9cdbf8aaf87198020389ba14f92d9784740c91c Mon Sep 17 00:00:00 2001 From: Isvane <277444536+Isvane@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:33:31 +0700 Subject: [PATCH 45/52] fix(uri): reject DEL character (#842) --- src/uri/path.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/uri/path.rs b/src/uri/path.rs index 1c7e18c6..8cedaba8 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -455,7 +455,7 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { 0x7E => {} // potentially utf8, might not, should check - 0x7F..=0xFF => { + 0x80..=0xFF => { is_maybe_not_utf8 = true; } @@ -492,7 +492,7 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { 0x3D | 0x3F..=0x7E => {} - 0x7F..=0xFF => { + 0x80..=0xFF => { is_maybe_not_utf8 = true; } @@ -647,6 +647,16 @@ mod tests { PathAndQuery::try_from("sneaky").expect_err("reject missing slash"); } + #[test] + fn rejects_del_in_path() { + PathAndQuery::try_from(&[b'/', 0x7F][..]).expect_err("reject DEL"); + } + + #[test] + fn rejects_del_in_query() { + PathAndQuery::try_from(&[b'/', b'a', b'?', 0x7F][..]).expect_err("reject DEL"); + } + #[test] fn json_is_fine() { assert_eq!( From 82db5b8af1e3939678fee88f3c57b72fee7e3a7b Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 8 Jun 2026 09:35:13 -0400 Subject: [PATCH 46/52] v1.4.2 --- CHANGELOG.md | 5 +++++ Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 211a8301..5ea0ca01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.4.2 (June 8, 2026) + +- Fix `uri::Builder` to allow `"*"` as the path when scheme and authority are also set, used in HTTP/2 requests. +- Fix `Uri` to properly reject `DEL` characters. + # 1.4.1 (May 25, 2026) - Fix `PathAndQuery::from_static()` and `from_shared()` to reject inputs that do not start with `/`. diff --git a/Cargo.toml b/Cargo.toml index 478a144b..bcce0d10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "http" # - Update html_root_url in lib.rs. # - Update CHANGELOG.md. # - Create git tag -version = "1.4.1" +version = "1.4.2" readme = "README.md" documentation = "https://docs.rs/http" repository = "https://github.com/hyperium/http" From bb8705b25cdb6e29081edf9ade2ea124f6783e18 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 16 Jun 2026 10:17:41 -0400 Subject: [PATCH 47/52] feat(method): add QUERY method (#798) --- src/method.rs | 9 ++++++++- src/request.rs | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/method.rs b/src/method.rs index fc535d4f..e945c1de 100644 --- a/src/method.rs +++ b/src/method.rs @@ -60,6 +60,7 @@ enum Inner { Trace, Connect, Patch, + Query, // If the extension is short enough, store it inline ExtensionInline(InlineExtension), // Otherwise, allocate it @@ -94,6 +95,9 @@ impl Method { /// TRACE pub const TRACE: Method = Method(Trace); + /// QUERY + pub const QUERY: Method = Method(Query); + /// Converts a slice of bytes to an HTTP method. pub fn from_bytes(src: &[u8]) -> Result { match src.len() { @@ -111,6 +115,7 @@ impl Method { 5 => match src { b"PATCH" => Ok(Method(Patch)), b"TRACE" => Ok(Method(Trace)), + b"QUERY" => Ok(Method(Query)), _ => Method::extension_inline(src), }, 6 => match src { @@ -146,7 +151,7 @@ impl Method { /// See [the spec](https://tools.ietf.org/html/rfc7231#section-4.2.1) /// for more words. pub fn is_safe(&self) -> bool { - matches!(self.0, Get | Head | Options | Trace) + matches!(self.0, Get | Head | Options | Trace | Query) } /// Whether a method is considered "idempotent", meaning the request has @@ -174,6 +179,7 @@ impl Method { Trace => "TRACE", Connect => "CONNECT", Patch => "PATCH", + Query => "QUERY", ExtensionInline(ref inline) => inline.as_str(), ExtensionAllocated(ref allocated) => allocated.as_str(), } @@ -466,6 +472,7 @@ mod test { assert!(Method::DELETE.is_idempotent()); assert!(Method::HEAD.is_idempotent()); assert!(Method::TRACE.is_idempotent()); + assert!(Method::QUERY.is_idempotent()); assert!(!Method::POST.is_idempotent()); assert!(!Method::CONNECT.is_idempotent()); diff --git a/src/request.rs b/src/request.rs index 7cf19fa5..f47c6630 100644 --- a/src/request.rs +++ b/src/request.rs @@ -407,6 +407,10 @@ impl Request<()> { { Builder::new().method(Method::TRACE).uri(uri) } + + // This is purposefully excluded because of potential conflict with the + // URI query. + // pub fn query() -> Builder } impl Request { From 03c8cd7faeddfad00873b4d58a45ecdf74ebebe6 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 17 Jul 2026 10:50:10 -0400 Subject: [PATCH 48/52] fix(uri): allow empty paths in uri::Builder (#853) cc #839 --- src/error.rs | 7 +++++++ src/uri/builder.rs | 38 +++++++++++++++++++++++++++++++++++++- src/uri/mod.rs | 4 ++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 762ee1c2..192a953b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -47,6 +47,13 @@ impl fmt::Display for Error { } impl Error { + pub(crate) fn is_empty_uri(&self) -> bool { + match self.inner { + ErrorKind::Uri(ref err) => err.is_empty(), + _ => false, + } + } + /// Return true if the underlying error has the same type as T. pub fn is(&self) -> bool { self.get_ref().is::() diff --git a/src/uri/builder.rs b/src/uri/builder.rs index 06c7ed6c..046a116a 100644 --- a/src/uri/builder.rs +++ b/src/uri/builder.rs @@ -96,7 +96,17 @@ impl Builder { >::Error: Into, { self.map(move |mut parts| { - let p_and_q = p_and_q.try_into().map_err(Into::into)?; + let p_and_q = match p_and_q.try_into() { + Ok(p_and_q) => p_and_q, + Err(err) => { + let err = err.into(); + if err.is_empty_uri() { + PathAndQuery::empty() + } else { + return Err(err); + } + } + }; parts.path_and_query = Some(p_and_q); Ok(parts) }) @@ -202,6 +212,32 @@ mod tests { } } + #[test] + fn build_from_empty_path_and_query() { + let uri = Builder::new() + .scheme(Scheme::HTTP) + .authority("localhost:8080") + .path_and_query("") + .build() + .unwrap(); + + assert_eq!(uri, "http://localhost:8080"); + assert_eq!(uri.path(), "/"); + } + + #[test] + fn empty_path_and_query_remains_strict() { + assert!(PathAndQuery::try_from("").is_err()); + } + + #[test] + fn authority_form_path_and_query_remains_strict() { + assert!(Builder::new() + .path_and_query("localhost:8080") + .build() + .is_err()); + } + #[test] fn build_from_uri() { let original_uri = Uri::default(); diff --git a/src/uri/mod.rs b/src/uri/mod.rs index 16b45c84..cbb3617c 100644 --- a/src/uri/mod.rs +++ b/src/uri/mod.rs @@ -1068,6 +1068,10 @@ impl From for InvalidUriParts { } impl InvalidUri { + pub(crate) fn is_empty(&self) -> bool { + self.0 == ErrorKind::Empty + } + fn s(&self) -> &str { match self.0 { ErrorKind::InvalidUriChar => "invalid uri character", From 2178e175c4e247a33ba5f6ca3503afb1afbaabba Mon Sep 17 00:00:00 2001 From: Martin Taillefer Date: Thu, 23 Jul 2026 06:06:07 -0700 Subject: [PATCH 49/52] perf(header,uri): faster value validation, URI parse/format, map inserts (#852) Four independent, benchmark-validated micro-optimizations: * HeaderValue validation (from_bytes / from_maybe_shared / to_str): replace the early-return byte loop with a branchless OR-accumulation so the scan auto-vectorizes. The valid case scans the whole slice either way; the error path is rare. * URI path/query scan: replace the per-byte range `match` (~10 comparisons per byte) with two compile-time 256-entry classification tables. Stays a const fn (used by from_static); verified to classify all 256 bytes identically to the old match in both the path and query states. * URI formatting: Uri and PathAndQuery Display impls use direct write_str calls instead of write!/format-args machinery for their string parts. * HeaderMap: mark hash_elem_using #[inline] so it specializes into the insert path. Adds a criterion wallclock bench (opt_paths) for the affected operations. Before/after, gungraun instruction counts (deterministic): HeaderValue::from_bytes short 118 -> 98 -16.9% HeaderValue::from_bytes long 950 -> 196 -79.4% HeaderValue::to_str short 175 -> 125 -28.6% HeaderValue::to_str long 1319 -> 217 -83.5% Uri parse relative_medium 1348 -> 1009 -25.1% Uri parse relative_query 1779 -> 1194 -32.9% Uri to_string relative 553 -> 448 -19.0% Uri to_string relative_query 995 -> 795 -20.1% Uri to_string absolute 1873 -> 1525 -18.6% HeaderMap insert_all_std 19914 -> 18850 -5.3% HeaderMap set_10_std (n10) 3343 -> 2784 -16.7% HeaderMap set_20_std (n20) 6221 -> 5102 -18.0% HeaderMap hn_hdrs_set_8_get_miss 3453 -> 3076 -10.9% HeaderMap insert_custom (n500) 525783 -> 519283 -1.2% Before/after, criterion wallclock (median; shared VM, noisy under ~100ns): hv_from_bytes_long 89.4 ns -> 33.3 ns -62.8% hv_to_str_long 89.8 ns -> 9.2 ns -89.8% hv_to_str_short 7.2 ns -> 3.9 ns -46.6% uri_parse_relative_medium 130.9 ns -> 112.3 ns -14.2% uri_parse_relative_query 196.6 ns -> 177.7 ns -9.6% uri_to_string_relative 34.2 ns -> 26.2 ns -23.4% uri_to_string_relative_query 112.7 ns -> 93.2 ns -17.3% uri_to_string_absolute 193.4 ns -> 171.4 ns -11.4% hm_insert_10_std 300.7 ns -> 296.5 ns -1.4% (hv_from_bytes_short is within wallclock noise here at ~28 ns; its gungraun instruction count drops 16.9%.) --- benches/Cargo.toml | 9 +++ benches/src/opt_paths.rs | 68 ++++++++++++++++++++++ src/header/map.rs | 1 + src/header/value.rs | 18 ++++-- src/uri/mod.rs | 10 ++-- src/uri/path.rs | 123 ++++++++++++++++++++++----------------- 6 files changed, 167 insertions(+), 62 deletions(-) create mode 100644 benches/src/opt_paths.rs diff --git a/benches/Cargo.toml b/benches/Cargo.toml index 0d881f52..789980e4 100644 --- a/benches/Cargo.toml +++ b/benches/Cargo.toml @@ -4,6 +4,10 @@ version = "0.0.0" edition = "2018" publish = false +# `benches` is excluded from the root workspace, so declare an empty workspace +# table to let it build as a standalone package. +[workspace] + [dependencies] bytes = "1" fnv = "1.0.5" @@ -41,3 +45,8 @@ path = "src/method.rs" [[bench]] name = "uri" path = "src/uri.rs" + +[[bench]] +name = "opt_paths" +path = "src/opt_paths.rs" +harness = false diff --git a/benches/src/opt_paths.rs b/benches/src/opt_paths.rs new file mode 100644 index 00000000..fc164ac5 --- /dev/null +++ b/benches/src/opt_paths.rs @@ -0,0 +1,68 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use http::header::*; +use http::{HeaderValue, Uri}; + +static SHORT: &[u8] = b"localhost"; +static LONG: &[u8] = b"Mozilla/5.0 (X11; CrOS x86_64 9592.71.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.80 Safari/537.36"; + +const REL: &str = "/wp-content/uploads/2010/03/hello-kitty-darth-vader-pink.jpg"; +const REL_QUERY: &str = "/wp-content/uploads/2010/03/hello-kitty-darth-vader-pink.jpg?foo=bar&baz=quux"; +const ABS: &str = "https://www.example.com/wp-content/uploads/hello.jpg?foo=bar"; + +const STD: &[HeaderName] = &[ + HOST, CONTENT_TYPE, CONTENT_LENGTH, ACCEPT, ACCEPT_ENCODING, USER_AGENT, + CONNECTION, CACHE_CONTROL, DATE, SERVER, +]; + +fn header_value(c: &mut Criterion) { + c.bench_function("hv_from_bytes_short", |b| { + b.iter(|| HeaderValue::from_bytes(black_box(SHORT)).unwrap()) + }); + c.bench_function("hv_from_bytes_long", |b| { + b.iter(|| HeaderValue::from_bytes(black_box(LONG)).unwrap()) + }); + let short = HeaderValue::from_bytes(SHORT).unwrap(); + let long = HeaderValue::from_bytes(LONG).unwrap(); + c.bench_function("hv_to_str_short", |b| { + b.iter(|| black_box(&short).to_str().unwrap()) + }); + c.bench_function("hv_to_str_long", |b| { + b.iter(|| black_box(&long).to_str().unwrap()) + }); +} + +fn uri(c: &mut Criterion) { + c.bench_function("uri_parse_relative_medium", |b| { + b.iter(|| black_box(REL).parse::().unwrap()) + }); + c.bench_function("uri_parse_relative_query", |b| { + b.iter(|| black_box(REL_QUERY).parse::().unwrap()) + }); + let rel: Uri = REL.parse().unwrap(); + let rel_query: Uri = REL_QUERY.parse().unwrap(); + let abs: Uri = ABS.parse().unwrap(); + c.bench_function("uri_to_string_relative", |b| { + b.iter(|| black_box(&rel).to_string()) + }); + c.bench_function("uri_to_string_relative_query", |b| { + b.iter(|| black_box(&rel_query).to_string()) + }); + c.bench_function("uri_to_string_absolute", |b| { + b.iter(|| black_box(&abs).to_string()) + }); +} + +fn header_map(c: &mut Criterion) { + c.bench_function("hm_insert_10_std", |b| { + b.iter(|| { + let mut m = HeaderMap::default(); + for hdr in STD { + m.insert(hdr.clone(), "foo"); + } + black_box(m) + }) + }); +} + +criterion_group!(benches, header_value, uri, header_map); +criterion_main!(benches); diff --git a/src/header/map.rs b/src/header/map.rs index 58fbaace..6a7628e1 100644 --- a/src/header/map.rs +++ b/src/header/map.rs @@ -3721,6 +3721,7 @@ fn probe_distance(mask: Size, hash: HashValue, current: usize) -> usize { current.wrapping_sub(desired_pos(mask, hash)) & mask as usize } +#[inline] fn hash_elem_using(danger: &Danger, k: &K) -> HashValue where K: Hash + ?Sized, diff --git a/src/header/value.rs b/src/header/value.rs index abd5d036..5a912d9f 100644 --- a/src/header/value.rs +++ b/src/header/value.rs @@ -213,10 +213,13 @@ impl HeaderValue { src: T, into: F, ) -> Result { + // Avoid an early return so the loop vectorizes. + let mut bad = false; for &b in src.as_ref() { - if !is_valid(b) { - return Err(InvalidHeaderValue { _priv: () }); - } + bad |= !is_valid(b); + } + if bad { + return Err(InvalidHeaderValue { _priv: () }); } Ok(HeaderValue { inner: into(src), @@ -240,10 +243,13 @@ impl HeaderValue { pub fn to_str(&self) -> Result<&str, ToStrError> { let bytes = self.as_ref(); + // Avoid an early return so the loop vectorizes. + let mut bad = false; for &b in bytes { - if !is_visible_ascii(b) { - return Err(ToStrError { _priv: () }); - } + bad |= !is_visible_ascii(b); + } + if bad { + return Err(ToStrError { _priv: () }); } unsafe { Ok(str::from_utf8_unchecked(bytes)) } diff --git a/src/uri/mod.rs b/src/uri/mod.rs index cbb3617c..9a551819 100644 --- a/src/uri/mod.rs +++ b/src/uri/mod.rs @@ -1032,17 +1032,19 @@ impl Default for Uri { impl fmt::Display for Uri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(scheme) = self.scheme() { - write!(f, "{}://", scheme)?; + f.write_str(scheme.as_str())?; + f.write_str("://")?; } if let Some(authority) = self.authority() { - write!(f, "{}", authority)?; + f.write_str(authority.as_str())?; } - write!(f, "{}", self.path())?; + f.write_str(self.path())?; if let Some(query) = self.query() { - write!(f, "?{}", query)?; + f.write_str("?")?; + f.write_str(query)?; } Ok(()) diff --git a/src/uri/path.rs b/src/uri/path.rs index 8cedaba8..a9836a33 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -278,11 +278,14 @@ impl fmt::Display for PathAndQuery { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { if !self.data.is_empty() { match self.data.as_bytes()[0] { - b'/' | b'*' => write!(fmt, "{}", &self.data[..]), - _ => write!(fmt, "/{}", &self.data[..]), + b'/' | b'*' => fmt.write_str(&self.data), + _ => { + fmt.write_str("/")?; + fmt.write_str(&self.data) + } } } else { - write!(fmt, "/") + fmt.write_str("/") } } } @@ -405,6 +408,62 @@ struct Scanned { is_maybe_not_utf8: bool, } +// Per-byte character classes for the path and query scanners. +const CLASS_VALID: u8 = 0; +const CLASS_QUERY: u8 = 1; +const CLASS_FRAGMENT: u8 = 2; +const CLASS_HIGH: u8 = 3; +const CLASS_INVALID: u8 = 4; + +const fn build_path_map() -> [u8; 256] { + let mut t = [CLASS_INVALID; 256]; + let mut i = 0; + while i < 256 { + // See https://url.spec.whatwg.org/#path-state + t[i] = match i as u8 { + b'?' => CLASS_QUERY, + b'#' => CLASS_FRAGMENT, + + // Bytes that don't need to be percent-encoded in the path. + 0x21 | 0x24..=0x3B | 0x3D | 0x40..=0x5F | 0x61..=0x7A | 0x7C | 0x7E => CLASS_VALID, + + // Potentially utf8, checked later. + 0x80..=0xFF => CLASS_HIGH, + + // Should be percent-encoded, but accepted for parity with clients + // that send them as-is (e.g. JSON embedded in the path). + b'"' | b'{' | b'}' => CLASS_VALID, + + _ => CLASS_INVALID, + }; + i += 1; + } + t +} + +const fn build_query_map() -> [u8; 256] { + let mut t = [CLASS_INVALID; 256]; + let mut i = 0; + while i < 256 { + // See https://url.spec.whatwg.org/#query-state + t[i] = match i as u8 { + b'#' => CLASS_FRAGMENT, + + // Allowed: 0x21 / 0x24 - 0x3B / 0x3D / 0x3F - 0x7E + 0x21 | 0x24..=0x3B | 0x3D | 0x3F..=0x7E => CLASS_VALID, + + 0x80..=0xFF => CLASS_HIGH, + + _ => CLASS_INVALID, + }; + i += 1; + } + t +} + +const PATH_MAP: [u8; 256] = build_path_map(); +const QUERY_MAP: [u8; 256] = build_query_map(); + const fn scan_path_and_query(bytes: &[u8]) -> Result { let mut i = 0; let mut query = NONE; @@ -429,49 +488,21 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { } while i < bytes.len() { - // See https://url.spec.whatwg.org/#path-state - match bytes[i] { - b'?' => { + match PATH_MAP[bytes[i] as usize] { + CLASS_VALID => {} + CLASS_QUERY => { debug_assert!(query == NONE); query = i as u16; i += 1; break; } - b'#' => { + CLASS_FRAGMENT => { fragment = Some(i as u16); break; } - - // This is the range of bytes that don't need to be - // percent-encoded in the path. If it should have been - // percent-encoded, then error. - #[rustfmt::skip] - 0x21 | - 0x24..=0x3B | - 0x3D | - 0x40..=0x5F | - 0x61..=0x7A | - 0x7C | - 0x7E => {} - - // potentially utf8, might not, should check - 0x80..=0xFF => { + CLASS_HIGH => { is_maybe_not_utf8 = true; } - - // These are code points that are supposed to be - // percent-encoded in the path but there are clients - // out there sending them as is and httparse accepts - // to parse those requests, so they are allowed here - // for parity. - // - // For reference, those are code points that are used - // to send requests with JSON directly embedded in - // the URI path. Yes, those things happen for real. - #[rustfmt::skip] - b'"' | - b'{' | b'}' => {} - _ => return Err(ErrorKind::InvalidUriChar), } i += 1; @@ -480,27 +511,15 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { // query ... if query != NONE { while i < bytes.len() { - match bytes[i] { - // While queries *should* be percent-encoded, most - // bytes are actually allowed... - // See https://url.spec.whatwg.org/#query-state - // - // Allowed: 0x21 / 0x24 - 0x3B / 0x3D / 0x3F - 0x7E - #[rustfmt::skip] - 0x21 | - 0x24..=0x3B | - 0x3D | - 0x3F..=0x7E => {} - - 0x80..=0xFF => { + match QUERY_MAP[bytes[i] as usize] { + CLASS_VALID => {} + CLASS_HIGH => { is_maybe_not_utf8 = true; } - - b'#' => { + CLASS_FRAGMENT => { fragment = Some(i as u16); break; } - _ => return Err(ErrorKind::InvalidUriChar), } i += 1; From e559023f67e3fad6ecc3ee91307be178e0f13626 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 28 Jul 2026 12:58:38 -0400 Subject: [PATCH 50/52] fix(uri): enforce max length in PathAndQuery (#856) --- src/uri/path.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/uri/path.rs b/src/uri/path.rs index a9836a33..f62a6867 100644 --- a/src/uri/path.rs +++ b/src/uri/path.rs @@ -4,7 +4,7 @@ use std::{cmp, fmt, hash, str}; use bytes::Bytes; -use super::{ErrorKind, InvalidUri}; +use super::{ErrorKind, InvalidUri, MAX_LEN}; use crate::byte_str::ByteStr; /// Represents the path component of a URI @@ -475,6 +475,10 @@ const fn scan_path_and_query(bytes: &[u8]) -> Result { return Err(ErrorKind::Empty); } + if bytes.len() > MAX_LEN { + return Err(ErrorKind::TooLong); + } + if bytes.len() == 1 && bytes[0] == b'*' { return Ok(Scanned { query, @@ -676,6 +680,21 @@ mod tests { PathAndQuery::try_from(&[b'/', b'a', b'?', 0x7F][..]).expect_err("reject DEL"); } + #[test] + fn rejects_too_long_path_and_query() { + let path = format!("/{}?query", "a".repeat(MAX_LEN)); + let err = PathAndQuery::try_from(path).expect_err("reject overly long path and query"); + assert_eq!(err.0, ErrorKind::TooLong); + } + + #[test] + fn accepts_max_length_path_and_query() { + let path = format!("/{}?", "a".repeat(MAX_LEN - 2)); + let path_and_query = PathAndQuery::try_from(path).expect("accept maximum length"); + assert_eq!(path_and_query.as_str().len(), MAX_LEN); + assert_eq!(path_and_query.query(), Some("")); + } + #[test] fn json_is_fine() { assert_eq!( From 16fc9a7b840c2181e7f8b37397c107b0ffcd050d Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 29 Jul 2026 10:54:34 -0400 Subject: [PATCH 51/52] v1.5.0 --- CHANGELOG.md | 6 ++++++ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea0ca01..76ec753f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.5.0 (July 29, 2026) + +- Add `Method::QUERY` constant for the new QUERY method defined in RFC 10008. +- Fix `uri::Builder::path_and_query()` to allow empty strings to mean no path. +- Fix `uri::PathAndQuery` parsing to enforce URI max length. + # 1.4.2 (June 8, 2026) - Fix `uri::Builder` to allow `"*"` as the path when scheme and authority are also set, used in HTTP/2 requests. diff --git a/Cargo.toml b/Cargo.toml index bcce0d10..b0ba476b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ name = "http" # - Update html_root_url in lib.rs. # - Update CHANGELOG.md. # - Create git tag -version = "1.4.2" +version = "1.5.0" readme = "README.md" documentation = "https://docs.rs/http" repository = "https://github.com/hyperium/http" From 4d18d3ea731c6267ce0d26bc04ae394a786ed3f0 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 10 Aug 2026 10:43:33 -0400 Subject: [PATCH 52/52] fix(header): enforce string constructors to only allow ASCII (#860) --- src/header/value.rs | 68 +++++++++++++++++++++++++++++++++++---------- tests/header_map.rs | 2 +- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/header/value.rs b/src/header/value.rs index 5a912d9f..97bb49ef 100644 --- a/src/header/value.rs +++ b/src/header/value.rs @@ -44,7 +44,7 @@ impl HeaderValue { /// /// This function will not perform any copying, however the string is /// checked to ensure that no invalid characters are present. Only visible - /// ASCII characters (32-127) are permitted. + /// ASCII characters (32-126) and horizontal tab are permitted. /// /// # Panics /// @@ -63,7 +63,7 @@ impl HeaderValue { let bytes = src.as_bytes(); let mut i = 0; while i < bytes.len() { - if !is_visible_ascii(bytes[i]) { + if !is_valid_ascii(bytes[i]) { panic!("HeaderValue::from_static with invalid bytes") } i += 1; @@ -78,7 +78,8 @@ impl HeaderValue { /// Attempt to convert a string to a `HeaderValue`. /// /// If the argument contains invalid header value characters, an error is - /// returned. Only visible ASCII characters (32-127) are permitted. Use + /// returned. Only visible ASCII characters (32-126) and horizontal tab are + /// permitted. Use /// `from_bytes` to create a `HeaderValue` that includes opaque octets /// (128-255). /// @@ -103,7 +104,11 @@ impl HeaderValue { #[inline] #[allow(clippy::should_implement_trait)] pub fn from_str(src: &str) -> Result { - HeaderValue::try_from_generic(src, |s| Bytes::copy_from_slice(s.as_bytes())) + HeaderValue::try_from_generic( + src, + |s| Bytes::copy_from_slice(s.as_bytes()), + is_valid_ascii, + ) } /// Converts a HeaderName into a HeaderValue @@ -149,7 +154,7 @@ impl HeaderValue { /// ``` #[inline] pub fn from_bytes(src: &[u8]) -> Result { - HeaderValue::try_from_generic(src, Bytes::copy_from_slice) + HeaderValue::try_from_generic(src, Bytes::copy_from_slice, is_valid_ascii_or_opaque_byte) } /// Attempt to convert a `Bytes` buffer to a `HeaderValue`. @@ -206,12 +211,13 @@ impl HeaderValue { } fn from_shared(src: Bytes) -> Result { - HeaderValue::try_from_generic(src, std::convert::identity) + HeaderValue::try_from_generic(src, std::convert::identity, is_valid_ascii_or_opaque_byte) } - fn try_from_generic, F: FnOnce(T) -> Bytes>( + fn try_from_generic, F: FnOnce(T) -> Bytes, V: Fn(u8) -> bool>( src: T, into: F, + is_valid: V, ) -> Result { // Avoid an early return so the loop vectorizes. let mut bad = false; @@ -246,7 +252,7 @@ impl HeaderValue { // Avoid an early return so the loop vectorizes. let mut bad = false; for &b in bytes { - bad |= !is_visible_ascii(b); + bad |= !is_valid_ascii(b); } if bad { return Err(ToStrError { _priv: () }); @@ -369,7 +375,7 @@ impl fmt::Debug for HeaderValue { let mut from = 0; let bytes = self.as_bytes(); for (i, &b) in bytes.iter().enumerate() { - if !is_visible_ascii(b) || b == b'"' { + if !is_valid_ascii(b) || b == b'"' { if from != i { f.write_str(unsafe { str::from_utf8_unchecked(&bytes[from..i]) })?; } @@ -417,7 +423,7 @@ macro_rules! from_integers { let val = HeaderValue::from(n); assert_eq!(val, &n.to_string()); - let n = ::std::$t::MAX; + let n = <$t>::MAX; let val = HeaderValue::from(n); assert_eq!(val, &n.to_string()); } @@ -510,7 +516,7 @@ impl TryFrom<&String> for HeaderValue { type Error = InvalidHeaderValue; #[inline] fn try_from(s: &String) -> Result { - Self::from_bytes(s.as_bytes()) + Self::from_str(s) } } @@ -528,7 +534,7 @@ impl TryFrom for HeaderValue { #[inline] fn try_from(t: String) -> Result { - HeaderValue::from_shared(t.into()) + HeaderValue::try_from_generic(t, |s| s.into(), is_valid_ascii) } } @@ -555,12 +561,15 @@ mod try_from_header_name_tests { } } -const fn is_visible_ascii(b: u8) -> bool { +const fn is_valid_ascii(b: u8) -> bool { b >= 32 && b < 127 || b == b'\t' } +// This validator is only for byte-oriented constructors. HTTP field values +// may contain opaque bytes, even though those bytes cannot be exposed by +// `HeaderValue::to_str`. #[inline] -fn is_valid(b: u8) -> bool { +fn is_valid_ascii_or_opaque_byte(b: u8) -> bool { b >= 32 && b != 127 || b == b'\t' } @@ -756,6 +765,37 @@ fn test_try_from() { HeaderValue::try_from(vec![127]).unwrap_err(); } +#[test] +fn test_string_constructors_reject_non_ascii() { + let value = String::from("hello \u{e9}"); + + assert!(HeaderValue::from_str(&value).is_err()); + assert!(HeaderValue::try_from(value.as_str()).is_err()); + assert!(HeaderValue::try_from(&value).is_err()); + assert!(HeaderValue::try_from(value).is_err()); +} + +#[test] +fn test_byte_constructors_allow_opaque_bytes_but_reject_del() { + assert!(HeaderValue::from_bytes(b"hello\xff").is_ok()); + assert!(HeaderValue::try_from(&b"hello\xff"[..]).is_ok()); + assert!(HeaderValue::try_from(b"hello\xff".to_vec()).is_ok()); + + assert!(HeaderValue::from_bytes(b"hello\x7f").is_err()); +} + +#[test] +fn test_string_and_byte_constructors_allow_horizontal_tab() { + assert!(HeaderValue::from_str("hello\tworld").is_ok()); + assert!(HeaderValue::from_bytes(b"hello\tworld").is_ok()); +} + +#[test] +#[should_panic(expected = "HeaderValue::from_static with invalid bytes")] +fn test_static_constructor_rejects_non_ascii() { + HeaderValue::from_static("hello \u{e9}"); +} + #[test] fn test_debug() { let cases = &[ diff --git a/tests/header_map.rs b/tests/header_map.rs index a7a75592..f6210135 100644 --- a/tests/header_map.rs +++ b/tests/header_map.rs @@ -77,7 +77,7 @@ fn extend_size_hint_above_capacity() { fn reserve_overflow() { // See https://github.com/hyperium/http/issues/352 let mut headers = HeaderMap::::with_capacity(0); - headers.reserve(std::usize::MAX); // next_power_of_two overflows + headers.reserve(usize::MAX); // next_power_of_two overflows } #[test]