Skip to content

Commit f264853

Browse files
committed
test(rust): make hyper-util's tunnel defects a test rather than a paragraph
This crate tunnels `CONNECT` itself because `hyper_util`'s `Tunnel` gets two cases wrong: a status line arriving split across reads falls into its catch-all refusal, and only `200` opens the tunnel where RFC 9110 says any 2xx does. Both are on hyper-util's main branch today, not only in the published 0.1.20. Written down, that justification goes stale silently. As a test it expires by itself: the day a release fixes either, this goes red and says what to delete. `tunnel_through` is also the shape the replacement would take, so the work is half done when the notice arrives. Measured against `Tunnel`, with an unsplit 200 as the control: plain 200: ACCEPTED, as it should be 201: REFUSED with `tunnel error: unsuccessful` split: REFUSED with `tunnel error: unsuccessful` The firing path was checked by making the 201 case answer 200: hyper-util now opens the tunnel on a 2xx other than 200. Good news: check the split-status-line case too, and if that is fixed as well, delete this crate's `tunnel` and use `hyper_util`'s. See #699. Nagle is off on the fixture's socket and the halves are 50ms apart, so a coalesced read cannot make the split case pass for the wrong reason; the message names that possibility anyway, since a false "upstream fixed it" is the one failure worth guarding against. `client-legacy` joins the declared features. This crate names `HttpConnector`, which lives behind it, and until now the feature was only on because `hyper-rustls` turns it on. `Cargo.lock` is unchanged: nothing new enters the graph. cargo test -p armonik-transport --all-features: 75 passed, 0 failed, and the new target five times over. clippy --all-targets --all-features -Dwarnings, cargo fmt --all --check, cargo build --workspace --locked --all-features: clean.
1 parent d286b47 commit f264853

2 files changed

Lines changed: 147 additions & 3 deletions

File tree

packages/rust/armonik-transport/Cargo.toml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ tonic = { workspace = true, features = ["channel", "codegen"] }
2020
snafu.workspace = true
2121
tracing.workspace = true
2222
hyper = { workspace = true, features = ["client", "http1", "http2"] }
23-
# `client-proxy` for the proxy matcher: the `*_PROXY` convention, `NO_PROXY` on curl's rules, and
24-
# taking credentials out of a proxy URL, none of which is worth writing again here.
25-
hyper-util = { workspace = true, features = ["client", "client-proxy", "http1"] }
23+
# `client-legacy` for `HttpConnector`, which this crate names: `hyper-rustls` happens to turn it on, and
24+
# depending on that would break the day it stops. `client-proxy` for the proxy matcher: the `*_PROXY`
25+
# convention, `NO_PROXY` on curl's rules, and taking credentials out of a proxy URL, none of which is
26+
# worth writing again here.
27+
hyper-util = { workspace = true, features = [
28+
"client",
29+
"client-legacy",
30+
"client-proxy",
31+
"http1",
32+
] }
2633
hyper-rustls = { workspace = true, features = [
2734
"http1",
2835
"http2",
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! Why this crate tunnels `CONNECT` itself instead of using `hyper_util`'s `Tunnel`.
2+
//!
3+
//! These tests assert that `Tunnel` still gets two cases wrong. They are meant to fail: when a
4+
//! `hyper-util` release fixes either, the failure is the notice that this crate's own tunnel can go,
5+
//! and `tunnel_through` below is the shape it would be replaced by. Deleting ours is tracked by #699,
6+
//! fixing them upstream by #702.
7+
//!
8+
//! A dependency bump turning CI red is the point. Read the failure before assuming it is a regression.
9+
10+
use hyper::Uri;
11+
use hyper_util::client::legacy::connect::proxy::Tunnel;
12+
use hyper_util::client::legacy::connect::HttpConnector;
13+
use hyper_util::rt::TokioIo;
14+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
15+
use tokio::net::{TcpListener, TcpStream};
16+
use tower_service::Service;
17+
18+
/// How long the split case waits between the two halves of its status line.
19+
///
20+
/// Long enough that the reader, which is already waiting, takes the first half on its own rather than
21+
/// finding both halves in one read. Nagle is off on that socket for the same reason.
22+
const SPLIT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
23+
24+
/// A proxy that answers any `CONNECT` with `head`, in one write or in two.
25+
///
26+
/// It never dials the target: what is under test is how the response is read, so there is nothing to
27+
/// tunnel to.
28+
async fn spawn_proxy(head: &'static str, split_at: Option<usize>) -> std::net::SocketAddr {
29+
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind proxy");
30+
let address = listener.local_addr().expect("proxy address");
31+
32+
tokio::spawn(async move {
33+
let Ok((mut client, _)) = listener.accept().await else {
34+
return;
35+
};
36+
// So the first half of a split status line leaves on its own segment rather than waiting for
37+
// the second and arriving as one read, which would make the split case pass for the wrong
38+
// reason.
39+
let _ = client.set_nodelay(true);
40+
41+
// Read to the blank line that ends the request head.
42+
let mut seen = Vec::new();
43+
loop {
44+
let mut byte = [0u8; 1];
45+
if client.read_exact(&mut byte).await.is_err() {
46+
return;
47+
}
48+
seen.push(byte[0]);
49+
if seen.ends_with(b"\r\n\r\n") {
50+
break;
51+
}
52+
}
53+
54+
// A closed connection is a different failure from a rejected status line, so the writes are
55+
// best effort and the socket is held open afterwards.
56+
match split_at {
57+
None => {
58+
let _ = client.write_all(head.as_bytes()).await;
59+
}
60+
Some(at) => {
61+
let _ = client.write_all(&head.as_bytes()[..at]).await;
62+
let _ = client.flush().await;
63+
tokio::time::sleep(SPLIT_DELAY).await;
64+
let _ = client.write_all(&head.as_bytes()[at..]).await;
65+
}
66+
}
67+
let _ = client.flush().await;
68+
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
69+
});
70+
71+
address
72+
}
73+
74+
/// Open a tunnel with `hyper_util`'s connector, which is what this crate would use if it could.
75+
async fn tunnel_through(proxy: std::net::SocketAddr) -> Result<TcpStream, String> {
76+
let proxy_uri = Uri::try_from(format!("http://{proxy}")).expect("proxy uri");
77+
let mut connector = HttpConnector::new();
78+
connector.enforce_http(false);
79+
80+
Tunnel::new(proxy_uri, connector)
81+
.call(Uri::try_from("https://example.invalid:443").expect("target uri"))
82+
.await
83+
.map(TokioIo::into_inner)
84+
.map_err(|error| error.to_string())
85+
}
86+
87+
/// The control. Without it, a broken fixture would read as upstream still being broken.
88+
#[tokio::test]
89+
async fn a_plain_200_opens_the_tunnel() {
90+
let proxy = spawn_proxy("HTTP/1.1 200 Connection established\r\n\r\n", None).await;
91+
92+
tunnel_through(proxy)
93+
.await
94+
.expect("the fixture itself must let an ordinary 200 through");
95+
}
96+
97+
#[tokio::test]
98+
async fn hyper_util_still_refuses_a_2xx_that_is_not_200() {
99+
// RFC 9110: any 2xx switches a `CONNECT` connection to tunnel mode. `Tunnel` compares the status
100+
// line against `200` alone, so a proxy answering 201 is treated as a refusal.
101+
let proxy = spawn_proxy("HTTP/1.1 201 Connection established\r\n\r\n", None).await;
102+
103+
let error = tunnel_through(proxy).await.err().unwrap_or_else(|| {
104+
panic!(
105+
"hyper-util now opens the tunnel on a 2xx other than 200. \
106+
Good news: check the split-status-line case too, and if that is fixed as well, \
107+
delete this crate's `tunnel` and use `hyper_util`'s. See #699."
108+
)
109+
});
110+
111+
assert!(
112+
error.contains("unsuccessful"),
113+
"unexpected failure: {error}"
114+
);
115+
}
116+
117+
#[tokio::test]
118+
async fn hyper_util_still_refuses_a_status_line_split_across_reads() {
119+
// Cut inside `HTTP/1.1 200`, so the first read carries `HTTP/1.1 2`, which matches neither prefix
120+
// `Tunnel` looks for and falls into its catch-all refusal. Legal, and likelier with a slow proxy
121+
// or a small MSS.
122+
let proxy = spawn_proxy("HTTP/1.1 200 Connection established\r\n\r\n", Some(10)).await;
123+
124+
let error = tunnel_through(proxy).await.err().unwrap_or_else(|| {
125+
panic!(
126+
"hyper-util now reads a split status line correctly, or the two writes reached it as \
127+
one read. Rule the second out before believing the first: the halves are 50ms apart \
128+
on a socket with Nagle off. If it really is fixed, check the 2xx case too, and if that \
129+
is fixed as well, delete this crate's `tunnel` and use `hyper_util`'s. See #699."
130+
)
131+
});
132+
133+
assert!(
134+
error.contains("unsuccessful"),
135+
"unexpected failure: {error}"
136+
);
137+
}

0 commit comments

Comments
 (0)