Skip to content

Commit e7be2e1

Browse files
committed
feat(ext): support non-canonical HTTP/1 reason phrases
Add a new extension type `hyper::ext::ReasonPhrase` gated by a new feature flag `http1_reason_phrase`. When enabled, store any non-canonical reason phrases in this extension when parsing responses, and write this reason phrase instead of the canonical reason phrase when emitting responses. Reason phrases are a disused corner of the spec that implementations ought to treat as opaque blobs of bytes. Unfortunately, real-world traffic sometimes does depend on being able to inspect and manipulate them. My main outstanding question is: should this new feature flag be included in `full`? Excluding it means this patch also has to modify the CI configuration in order to run the `client` and `server` tests.
1 parent 53f15e5 commit e7be2e1

File tree

8 files changed

+257
-27
lines changed

8 files changed

+257
-27
lines changed

.github/workflows/CI.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ jobs:
6161

6262
include:
6363
- rust: stable
64-
features: "--features full"
64+
features: "--features full,http1_reason_phrase"
6565
- rust: beta
66-
features: "--features full"
66+
features: "--features full,http1_reason_phrase"
6767
- rust: nightly
68-
features: "--features full,nightly"
68+
features: "--features full,http1_reason_phrase,nightly"
6969
benches: true
7070

7171
runs-on: ${{ matrix.os }}

Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ full = [
8686
http1 = []
8787
http2 = ["h2"]
8888

89+
# Support for non-canonical HTTP/1 reason phrases
90+
http1_reason_phrase = ["http1"]
91+
8992
# Client/Server
9093
client = []
9194
server = []
@@ -239,7 +242,7 @@ required-features = ["full"]
239242
[[test]]
240243
name = "client"
241244
path = "tests/client.rs"
242-
required-features = ["full"]
245+
required-features = ["full", "http1_reason_phrase"]
243246

244247
[[test]]
245248
name = "integration"
@@ -249,4 +252,4 @@ required-features = ["full"]
249252
[[test]]
250253
name = "server"
251254
path = "tests/server.rs"
252-
required-features = ["full"]
255+
required-features = ["full", "http1_reason_phrase"]

src/ext.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ use http::HeaderMap;
77
#[cfg(feature = "http2")]
88
use std::fmt;
99

10+
#[cfg(any(feature = "http1_reason_phrase", feature = "ffi"))]
11+
mod h1_reason_phrase;
12+
#[cfg(any(feature = "http1_reason_phrase", feature = "ffi"))]
13+
pub use h1_reason_phrase::ReasonPhrase;
14+
1015
#[cfg(feature = "http2")]
1116
/// Represents the `:protocol` pseudo-header used by
1217
/// the [Extended CONNECT Protocol].

src/ext/h1_reason_phrase.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
use std::str::Utf8Error;
2+
3+
use bytes::Bytes;
4+
5+
/// A reason phrase in an HTTP/1 response.
6+
///
7+
/// # Clients
8+
///
9+
/// For clients, a `ReasonPhrase` will be present in the extensions of the `http::Response` returned
10+
/// for a request if the reason phrase is different from the canonical reason phrase for the
11+
/// response's status code. For example, if a server returns `HTTP/1.1 200 Awesome`, the
12+
/// `ReasonPhrase` will be present and contain `Awesome`, but if a server returns `HTTP/1.1 200 OK`,
13+
/// the response will not contain a `ReasonPhrase`.
14+
///
15+
/// ```no_run
16+
/// # #[cfg(all(feature = "tcp", feature = "client", feature = "http1_reason_phrase"))]
17+
/// # async fn fake_fetch() -> hyper::Result<()> {
18+
/// use hyper::{Client, Uri};
19+
/// use hyper::ext::ReasonPhrase;
20+
///
21+
/// let res = Client::new().get(Uri::from_static("http://example.com/non_canonical_reason")).await?;
22+
///
23+
/// // Print out the non-canonical reason phrase, if it has one...
24+
/// if let Some(reason) = res.extensions().get::<ReasonPhrase>() {
25+
/// println!("non-canonical reason: {}", reason.to_str().unwrap());
26+
/// }
27+
/// # Ok(())
28+
/// # }
29+
/// ```
30+
///
31+
/// # Servers
32+
///
33+
/// When a `ReasonPhrase` is present in the extensions of the `http::Response` written by a server,
34+
/// its contents will be written in place of the canonical reason phrase when responding via HTTP/1.
35+
#[cfg(any(feature = "http1_reason_phrase", feature = "ffi"))]
36+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37+
pub struct ReasonPhrase(pub(crate) Bytes);
38+
39+
impl ReasonPhrase {
40+
/// Gets the reason phrase as bytes.
41+
pub fn as_bytes(&self) -> &[u8] {
42+
&self.0
43+
}
44+
45+
/// Gets the reason phrase as a `&str` if the phrase is valid UTF-8.
46+
pub fn to_str(&self) -> Result<&str, Utf8Error> {
47+
std::str::from_utf8(self.as_bytes())
48+
}
49+
50+
/// Converts a static byte slice to a reason phrase.
51+
pub const fn from_static(reason: &'static [u8]) -> Self {
52+
Self(Bytes::from_static(reason))
53+
}
54+
55+
/// Converts a static string to a reason phrase.
56+
pub const fn from_static_str(reason: &'static str) -> Self {
57+
Self(Bytes::from_static(reason.as_bytes()))
58+
}
59+
}
60+
61+
impl From<&'static [u8]> for ReasonPhrase {
62+
fn from(reason: &'static [u8]) -> Self {
63+
Self(reason.into())
64+
}
65+
}
66+
67+
impl From<&'static str> for ReasonPhrase {
68+
fn from(reason: &'static str) -> Self {
69+
Self(reason.into())
70+
}
71+
}
72+
73+
impl From<Vec<u8>> for ReasonPhrase {
74+
fn from(reason: Vec<u8>) -> Self {
75+
Self(reason.into())
76+
}
77+
}
78+
79+
impl From<String> for ReasonPhrase {
80+
fn from(reason: String) -> Self {
81+
Self(reason.into())
82+
}
83+
}
84+
85+
impl From<Bytes> for ReasonPhrase {
86+
fn from(reason: Bytes) -> Self {
87+
Self(reason)
88+
}
89+
}
90+
91+
impl Into<Bytes> for ReasonPhrase {
92+
fn into(self) -> Bytes {
93+
self.0
94+
}
95+
}
96+
97+
impl AsRef<[u8]> for ReasonPhrase {
98+
fn as_ref(&self) -> &[u8] {
99+
&self.0
100+
}
101+
}

src/ffi/http_types.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use super::body::{hyper_body, hyper_buf};
66
use super::error::hyper_code;
77
use super::task::{hyper_task_return_type, AsTaskType};
88
use super::{UserDataPointer, HYPER_ITER_CONTINUE};
9-
use crate::ext::HeaderCaseMap;
9+
use crate::ext::{HeaderCaseMap, ReasonPhrase};
1010
use crate::header::{HeaderName, HeaderValue};
1111
use crate::{Body, HeaderMap, Method, Request, Response, Uri};
1212

@@ -24,9 +24,6 @@ pub struct hyper_headers {
2424
orig_casing: HeaderCaseMap,
2525
}
2626

27-
#[derive(Debug)]
28-
pub(crate) struct ReasonPhrase(pub(crate) Bytes);
29-
3027
pub(crate) struct RawHeaders(pub(crate) hyper_buf);
3128

3229
pub(crate) struct OnInformational {

src/proto/h1/role.rs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ use std::mem::MaybeUninit;
33

44
#[cfg(all(feature = "server", feature = "runtime"))]
55
use tokio::time::Instant;
6-
#[cfg(any(test, feature = "server", feature = "ffi"))]
6+
#[cfg(any(
7+
test,
8+
feature = "server",
9+
feature = "ffi",
10+
feature = "http1_reason_phrase"
11+
))]
712
use bytes::Bytes;
813
use bytes::BytesMut;
914
#[cfg(feature = "server")]
@@ -358,7 +363,17 @@ impl Http1Transaction for Server {
358363

359364
let init_cap = 30 + msg.head.headers.len() * AVERAGE_HEADER_SIZE;
360365
dst.reserve(init_cap);
361-
if msg.head.version == Version::HTTP_11 && msg.head.subject == StatusCode::OK {
366+
367+
#[cfg(feature = "http1_reason_phrase")]
368+
let custom_reason_phrase = msg.head.extensions.get::<crate::ext::ReasonPhrase>();
369+
#[cfg(not(feature = "http1_reason_phrase"))]
370+
#[allow(non_upper_case_globals)]
371+
const custom_reason_phrase: Option<()> = None;
372+
373+
if msg.head.version == Version::HTTP_11
374+
&& msg.head.subject == StatusCode::OK
375+
&& custom_reason_phrase.is_none()
376+
{
362377
extend(dst, b"HTTP/1.1 200 OK\r\n");
363378
} else {
364379
match msg.head.version {
@@ -373,15 +388,26 @@ impl Http1Transaction for Server {
373388

374389
extend(dst, msg.head.subject.as_str().as_bytes());
375390
extend(dst, b" ");
376-
// a reason MUST be written, as many parsers will expect it.
377-
extend(
378-
dst,
379-
msg.head
380-
.subject
381-
.canonical_reason()
382-
.unwrap_or("<none>")
383-
.as_bytes(),
384-
);
391+
392+
if custom_reason_phrase.is_none() {
393+
// a reason MUST be written, as many parsers will expect it.
394+
extend(
395+
dst,
396+
msg.head
397+
.subject
398+
.canonical_reason()
399+
.unwrap_or("<none>")
400+
.as_bytes(),
401+
);
402+
}
403+
// This is mutually exclusive with the canonical reason phrase logic above, but due to
404+
// having to work around `cfg` attributes it is in a distinct conditional rather than an
405+
// `else`.
406+
#[cfg(feature = "http1_reason_phrase")]
407+
if let Some(crate::ext::ReasonPhrase(reason)) = custom_reason_phrase {
408+
extend(dst, reason);
409+
}
410+
385411
extend(dst, b"\r\n");
386412
}
387413

@@ -918,9 +944,9 @@ impl Http1Transaction for Client {
918944
trace!("Response.parse Complete({})", len);
919945
let status = StatusCode::from_u16(res.code.unwrap())?;
920946

921-
#[cfg(not(feature = "ffi"))]
947+
#[cfg(not(any(feature = "http1_reason_phrase", feature = "ffi")))]
922948
let reason = ();
923-
#[cfg(feature = "ffi")]
949+
#[cfg(any(feature = "http1_reason_phrase", feature = "ffi"))]
924950
let reason = {
925951
let reason = res.reason.unwrap();
926952
// Only save the reason phrase if it isnt the canonical reason
@@ -944,9 +970,9 @@ impl Http1Transaction for Client {
944970
Err(httparse::Error::Version) if ctx.h09_responses => {
945971
trace!("Response.parse accepted HTTP/0.9 response");
946972

947-
#[cfg(not(feature = "ffi"))]
973+
#[cfg(not(any(feature = "http1_reason_phrase", feature = "ffi")))]
948974
let reason = ();
949-
#[cfg(feature = "ffi")]
975+
#[cfg(any(feature = "http1_reason_phrase", feature = "ffi"))]
950976
let reason = None;
951977

952978
(0, StatusCode::OK, reason, Version::HTTP_09, 0)
@@ -1012,11 +1038,11 @@ impl Http1Transaction for Client {
10121038
extensions.insert(header_case_map);
10131039
}
10141040

1015-
#[cfg(feature = "ffi")]
1041+
#[cfg(any(feature = "http1_reason_phrase", feature = "ffi"))]
10161042
if let Some(reason) = reason {
1017-
extensions.insert(crate::ffi::ReasonPhrase(reason));
1043+
extensions.insert(crate::ext::ReasonPhrase(reason));
10181044
}
1019-
#[cfg(not(feature = "ffi"))]
1045+
#[cfg(not(any(feature = "http1_reason_phrase", feature = "ffi")))]
10201046
drop(reason);
10211047

10221048
#[cfg(feature = "ffi")]

tests/client.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2271,6 +2271,63 @@ mod conn {
22712271
future::join(server, client).await;
22722272
}
22732273

2274+
#[tokio::test]
2275+
async fn get_custom_reason_phrase() {
2276+
let _ = ::pretty_env_logger::try_init();
2277+
let listener = TkTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
2278+
.await
2279+
.unwrap();
2280+
let addr = listener.local_addr().unwrap();
2281+
2282+
let server = async move {
2283+
let mut sock = listener.accept().await.unwrap().0;
2284+
let mut buf = [0; 4096];
2285+
let n = sock.read(&mut buf).await.expect("read 1");
2286+
2287+
// Notably:
2288+
// - Just a path, since just a path was set
2289+
// - No host, since no host was set
2290+
let expected = "GET /a HTTP/1.1\r\n\r\n";
2291+
assert_eq!(s(&buf[..n]), expected);
2292+
2293+
sock.write_all(b"HTTP/1.1 200 Alright\r\nContent-Length: 0\r\n\r\n")
2294+
.await
2295+
.unwrap();
2296+
};
2297+
2298+
let client = async move {
2299+
let tcp = tcp_connect(&addr).await.expect("connect");
2300+
let (mut client, conn) = conn::handshake(tcp).await.expect("handshake");
2301+
2302+
tokio::task::spawn(async move {
2303+
conn.await.expect("http conn");
2304+
});
2305+
2306+
let req = Request::builder()
2307+
.uri("/a")
2308+
.body(Default::default())
2309+
.unwrap();
2310+
let mut res = client.send_request(req).await.expect("send_request");
2311+
assert_eq!(res.status(), hyper::StatusCode::OK);
2312+
assert_eq!(
2313+
res.extensions()
2314+
.get::<hyper::ext::ReasonPhrase>()
2315+
.expect("custom reason phrase is present")
2316+
.to_str()
2317+
.expect("reason phrase is utf-8"),
2318+
"Alright"
2319+
);
2320+
assert_eq!(res.headers().len(), 1);
2321+
assert_eq!(
2322+
res.headers().get(http::header::CONTENT_LENGTH).unwrap(),
2323+
"0"
2324+
);
2325+
assert!(res.body_mut().next().await.is_none());
2326+
};
2327+
2328+
future::join(server, client).await;
2329+
}
2330+
22742331
#[test]
22752332
fn incoming_content_length() {
22762333
use hyper::body::HttpBody;

0 commit comments

Comments
 (0)