use http::{
HeaderMap, Method, StatusCode,
header::{AUTHORIZATION, COOKIE, HOST, HeaderValue, LOCATION},
request::Parts,
uri::{PathAndQuery, Uri},
};
use motore::{layer::Layer, service::Service};
use volo::{client::Apply, context::Context};
use crate::{
body::Body,
client::{Target, target::RemoteHost},
context::ClientContext,
error::{
ClientError,
client::{Result, body_not_replayable, invalid_redirect_location, too_many_redirects},
},
request::Request,
response::Response,
};
const DEFAULT_MAX_REDIRECTS: usize = 10;
pub trait RedirectPredicate: Clone + Send + Sync + 'static {
fn should_follow(
&self,
target: &Target,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
) -> bool;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AlwaysRedirect;
impl RedirectPredicate for AlwaysRedirect {
fn should_follow(&self, _: &Target, _: &Method, _: &Uri, _: &HeaderMap) -> bool {
true
}
}
impl<F> RedirectPredicate for F
where
F: Fn(&Target, &Method, &Uri, &HeaderMap) -> bool + Clone + Send + Sync + 'static,
{
fn should_follow(
&self,
target: &Target,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
) -> bool {
self(target, method, uri, headers)
}
}
#[derive(Clone, Copy, Debug)]
pub struct FollowRedirect<P = AlwaysRedirect> {
max_redirects: usize,
predicate: P,
}
impl FollowRedirect<AlwaysRedirect> {
pub const fn new(max_redirects: usize) -> Self {
Self {
max_redirects,
predicate: AlwaysRedirect,
}
}
}
impl<P> FollowRedirect<P> {
pub fn when<Next>(self, predicate: Next) -> FollowRedirect<Next>
where
Next: RedirectPredicate,
{
FollowRedirect {
max_redirects: self.max_redirects,
predicate,
}
}
pub const fn max_redirects(&self) -> usize {
self.max_redirects
}
}
impl Default for FollowRedirect<AlwaysRedirect> {
fn default() -> Self {
Self::new(DEFAULT_MAX_REDIRECTS)
}
}
impl<S, P> Layer<S> for FollowRedirect<P>
where
P: RedirectPredicate,
{
type Service = FollowRedirectService<S, P>;
fn layer(self, inner: S) -> Self::Service {
FollowRedirectService {
inner,
max_redirects: self.max_redirects,
predicate: self.predicate,
}
}
}
pub struct FollowRedirectService<S, P = AlwaysRedirect> {
inner: S,
max_redirects: usize,
predicate: P,
}
impl<S, P, B, RespBody> Service<ClientContext, Request<B>> for FollowRedirectService<S, P>
where
P: RedirectPredicate,
B: Into<Body> + Send,
S: Service<ClientContext, Request<Body>, Response = Response<RespBody>, Error = ClientError>
+ Send
+ Sync,
{
type Response = Response<RespBody>;
type Error = ClientError;
async fn call(&self, cx: &mut ClientContext, req: Request<B>) -> Result<Self::Response> {
if self.max_redirects == 0
|| !self
.predicate
.should_follow(cx.target(), req.method(), req.uri(), req.headers())
{
let (parts, body) = req.into_parts();
return self
.inner
.call(cx, Request::from_parts(parts, body.into()))
.await;
}
let (mut parts, body) = req.into_parts();
let mut body: Body = body.into();
let mut redirects = 0usize;
loop {
let (this_body, can_replay_body) = match body.try_clone() {
Some(retained) => (std::mem::replace(&mut body, retained), true),
None => (std::mem::take(&mut body), false),
};
let req = Request::from_parts(parts.clone(), this_body);
let resp = self.inner.call(cx, req).await?;
let status = resp.status();
if !status_is_redirect(status) {
return Ok(resp);
}
if redirects >= self.max_redirects {
return Err(too_many_redirects());
}
let location = match resp.headers().get(LOCATION) {
Some(location) => location.clone(),
None => return Ok(resp),
};
let old_target = cx.target().clone();
let (target, uri) = resolve_redirect(&old_target, &parts.uri, &location)
.ok_or_else(invalid_redirect_location)?;
let changes_to_get = should_change_to_get(status, &parts.method);
if changes_to_get {
change_to_get(&mut parts);
body = Body::empty();
}
parts.uri = uri;
remove_sensitive_headers_on_origin_change(&mut parts, &old_target, &target);
update_host(&mut parts, &target);
super::utils::update_request_extension(&mut parts.extensions, &target);
if !self
.predicate
.should_follow(&target, &parts.method, &parts.uri, &parts.headers)
{
return Ok(resp);
}
if !changes_to_get && !can_replay_body {
return Err(body_not_replayable());
}
redirects += 1;
update_target(cx, target)?;
}
}
}
fn update_target(cx: &mut ClientContext, target: Target) -> Result<()> {
cx.rpc_info_mut().callee_mut().clear();
target.apply(cx)
}
fn update_host(parts: &mut Parts, target: &Target) {
if parts.headers.contains_key(HOST) {
parts.headers.remove(HOST);
if let Some(host) = super::header::gen_host(target) {
parts.headers.insert(HOST, host);
}
}
}
fn remove_sensitive_headers_on_origin_change(
parts: &mut Parts,
old_target: &Target,
new_target: &Target,
) {
if origin_changed(old_target, new_target) {
parts.headers.remove(AUTHORIZATION);
parts.headers.remove(COOKIE);
}
}
fn origin_changed(old_target: &Target, new_target: &Target) -> bool {
match (old_target.remote_ref(), new_target.remote_ref()) {
(Some(old), Some(new)) => {
old.scheme != new.scheme
|| old.port != new.port
|| remote_host_changed(&old.host, &new.host)
}
_ => true,
}
}
fn remote_host_changed(old_host: &RemoteHost, new_host: &RemoteHost) -> bool {
match (old_host, new_host) {
(RemoteHost::Ip(old), RemoteHost::Ip(new)) => old != new,
(RemoteHost::Name(old), RemoteHost::Name(new)) => {
!old.as_str().eq_ignore_ascii_case(new.as_str())
}
_ => true,
}
}
fn change_to_get(parts: &mut Parts) {
parts.method = Method::GET;
parts.headers.remove(http::header::CONTENT_TYPE);
parts.headers.remove(http::header::CONTENT_LENGTH);
parts.headers.remove(http::header::CONTENT_ENCODING);
parts.headers.remove(http::header::TRANSFER_ENCODING);
}
fn status_is_redirect(status: StatusCode) -> bool {
matches!(
status,
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::SEE_OTHER
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT
)
}
fn should_change_to_get(status: StatusCode, method: &Method) -> bool {
match status {
StatusCode::SEE_OTHER => method != Method::HEAD,
StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => method == Method::POST,
_ => false,
}
}
fn resolve_redirect(
target: &Target,
current_uri: &Uri,
location: &HeaderValue,
) -> Option<(Target, Uri)> {
let location = location.to_str().ok()?;
let base = url::Url::parse(&format!("{target}{current_uri}")).ok()?;
let resolved = base.join(location).ok()?;
let uri: Uri = resolved.as_str().parse().ok()?;
let target = Target::from_uri(&uri).ok()?;
let path_and_query = uri
.path_and_query()
.map(PathAndQuery::to_owned)
.unwrap_or_else(|| PathAndQuery::from_static("/"))
.into();
Some((target, path_and_query))
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use bytes::Bytes;
use http::{HeaderMap, Method, StatusCode, Uri, header};
use http_body::Frame;
use motore::service::Service;
use crate::{
ClientBuilder,
body::{Body, BodyConversion},
client::{Client, Target, layer::header::Host, test_helpers::MockTransport},
context::ClientContext,
error::ClientError,
request::{Request, RequestPartsExt},
response::Response,
};
#[derive(Clone, Debug, PartialEq, Eq)]
struct Hop {
target: String,
path: String,
host: Option<String>,
url_scheme: Option<String>,
authorization: Option<String>,
proxy_authorization: Option<String>,
cookie: Option<String>,
method: Method,
body: String,
}
#[derive(Clone, Default)]
struct RedirectMock {
hops: Arc<Mutex<Vec<Hop>>>,
}
impl RedirectMock {
fn hops(&self) -> Vec<Hop> {
self.hops.lock().unwrap().clone()
}
}
fn redirect_to(location: &str, status: StatusCode) -> Response {
let mut resp = Response::new(Body::empty());
*resp.status_mut() = status;
resp.headers_mut()
.insert(header::LOCATION, location.parse().unwrap());
resp
}
fn ok_with(body: &'static str) -> Response {
Response::new(Body::from(body))
}
impl Service<ClientContext, Request> for RedirectMock {
type Response = Response;
type Error = ClientError;
async fn call(
&self,
cx: &mut ClientContext,
req: Request,
) -> Result<Self::Response, Self::Error> {
let target = cx.target().to_string();
let path = req.uri().path().to_owned();
let host = req
.headers()
.get(header::HOST)
.map(|value| value.to_str().unwrap().to_owned());
let authorization = req
.headers()
.get(header::AUTHORIZATION)
.map(|value| value.to_str().unwrap().to_owned());
let proxy_authorization = req
.headers()
.get(header::PROXY_AUTHORIZATION)
.map(|value| value.to_str().unwrap().to_owned());
let cookie = req
.headers()
.get(header::COOKIE)
.map(|value| value.to_str().unwrap().to_owned());
let method = req.method().clone();
let url_scheme = req.url().map(|url| url.scheme().to_owned());
let body = req.into_body().into_string().await.unwrap();
self.hops.lock().unwrap().push(Hop {
target: target.clone(),
path: path.clone(),
host,
url_scheme,
authorization,
proxy_authorization,
cookie,
method: method.clone(),
body: body.clone(),
});
let resp = match (target.as_str(), path.as_str()) {
("http://first.local", "/cross") => {
redirect_to("http://second.local/landing", StatusCode::FOUND)
}
("http://second.local", "/landing") => ok_with("cross-host"),
("https://secure.local", "/to-http") => {
redirect_to("http://plain.local/final", StatusCode::FOUND)
}
("http://plain.local", "/to-https") => {
redirect_to("https://secure.local/final", StatusCode::FOUND)
}
("http://plain.local", "/final") => ok_with("plain"),
("https://secure.local", "/final") => ok_with("secure"),
(_, "/see-other") => redirect_to("/method", StatusCode::SEE_OTHER),
(_, "/temporary") => redirect_to("/method", StatusCode::TEMPORARY_REDIRECT),
(_, "/relative") => redirect_to("final", StatusCode::FOUND),
(_, "/final") => ok_with("relative"),
(_, "/method") => ok_with(if method == Method::GET && body.is_empty() {
"GET:"
} else if method == Method::POST && body == "payload" {
"POST:payload"
} else {
"unexpected"
}),
(_, "/loop") => redirect_to("/loop", StatusCode::FOUND),
(_, "/no-location") => {
let mut resp = Response::new(Body::empty());
*resp.status_mut() = StatusCode::FOUND;
resp
}
(_, "/bad-location") => redirect_to("ftp://example.com/next", StatusCode::FOUND),
_ => ok_with("default"),
};
Ok(resp)
}
}
#[tokio::test]
async fn follows_relative_location() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 10);
let body = client
.get("http://host.local/relative")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "relative");
assert_eq!(
mock.hops()
.into_iter()
.map(|hop| hop.path)
.collect::<Vec<_>>(),
vec!["/relative".to_owned(), "/final".to_owned()]
);
}
fn mock_client(mock: &RedirectMock, max_redirects: usize) -> Client {
ClientBuilder::new()
.follow_redirects(max_redirects)
.mock(MockTransport::service(mock.clone()))
.unwrap()
}
fn mock_client_with_host(mock: &RedirectMock, max_redirects: usize) -> Client {
ClientBuilder::new()
.follow_redirects(max_redirects)
.layer_outer_front(Host::Auto)
.mock(MockTransport::service(mock.clone()))
.unwrap()
}
fn predicate_client<P>(mock: &RedirectMock, predicate: P) -> Client
where
P: Fn(&Target, &Method, &Uri, &HeaderMap) -> bool + Clone + Send + Sync + 'static,
{
ClientBuilder::new()
.follow_redirects_when(10, predicate)
.mock(MockTransport::service(mock.clone()))
.unwrap()
}
#[tokio::test]
async fn predicate_skips_initial_request() {
let mock = RedirectMock::default();
let client = predicate_client(&mock, |_: &Target, _: &Method, uri: &Uri, _: &HeaderMap| {
uri.path() == "/allowed"
});
let resp = client
.get("http://host.local/relative")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "final");
assert_eq!(mock.hops().len(), 1);
}
#[tokio::test]
async fn predicate_stops_before_disallowed_next_hop() {
let mock = RedirectMock::default();
let client = predicate_client(
&mock,
|target: &Target, _: &Method, _: &Uri, _: &HeaderMap| {
target.to_string() == "http://first.local"
},
);
let resp = client.get("http://first.local/cross").send().await.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"http://second.local/landing"
);
assert_eq!(mock.hops().len(), 1);
}
#[tokio::test]
async fn follows_cross_host_and_updates_host() {
let mock = RedirectMock::default();
let client = mock_client_with_host(&mock, 10);
let body = client
.get("http://first.local/cross")
.header(header::AUTHORIZATION, "Bearer first")
.header(header::PROXY_AUTHORIZATION, "Basic proxy")
.header(header::COOKIE, "session=first")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "cross-host");
assert_eq!(
mock.hops(),
vec![
Hop {
target: "http://first.local".to_owned(),
path: "/cross".to_owned(),
host: Some("first.local".to_owned()),
url_scheme: Some("http".to_owned()),
authorization: Some("Bearer first".to_owned()),
proxy_authorization: Some("Basic proxy".to_owned()),
cookie: Some("session=first".to_owned()),
method: Method::GET,
body: String::new(),
},
Hop {
target: "http://second.local".to_owned(),
path: "/landing".to_owned(),
host: Some("second.local".to_owned()),
url_scheme: Some("http".to_owned()),
authorization: None,
proxy_authorization: Some("Basic proxy".to_owned()),
cookie: None,
method: Method::GET,
body: String::new(),
},
]
);
}
#[tokio::test]
async fn see_other_switches_to_get_and_drops_body() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 10);
let body = client
.post("http://host.local/see-other")
.data("payload")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "GET:");
}
#[tokio::test]
async fn temporary_redirect_preserves_method_and_body() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 10);
let body = client
.post("http://host.local/temporary")
.data("payload")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "POST:payload");
}
#[tokio::test]
async fn streaming_body_redirect_errors() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 10);
let stream = futures_util::stream::once(async {
Ok::<_, crate::error::BoxError>(Frame::data(Bytes::from_static(b"payload")))
});
let err = client
.post("http://host.local/temporary")
.body(Body::from_stream(stream))
.send()
.await
.unwrap_err();
assert!(
err.to_string()
.contains("request body is not replayable for redirect"),
"got: {err}"
);
assert_eq!(mock.hops().len(), 1);
}
#[tokio::test]
async fn zero_redirects_returns_redirect_response() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 0);
let resp = client
.get("http://host.local/relative")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "final");
assert_eq!(mock.hops().len(), 1);
}
#[tokio::test]
async fn too_many_redirects_errors() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 2);
let err = client
.get("http://host.local/loop")
.send()
.await
.unwrap_err();
assert!(err.to_string().contains("too many redirects"), "got: {err}");
assert_eq!(mock.hops().len(), 3);
}
#[tokio::test]
async fn missing_location_returns_redirect_response() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 10);
let resp = client
.get("http://host.local/no-location")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(mock.hops().len(), 1);
}
#[tokio::test]
async fn invalid_location_errors() {
let mock = RedirectMock::default();
let client = mock_client(&mock, 10);
let err = client
.get("http://host.local/bad-location")
.send()
.await
.unwrap_err();
assert!(
err.to_string()
.contains("invalid Location header in redirect response"),
"got: {err}"
);
assert_eq!(mock.hops().len(), 1);
}
#[cfg(feature = "__tls")]
#[tokio::test]
async fn redirect_updates_request_scheme_from_https_to_http() {
let mock = RedirectMock::default();
let client = mock_client_with_host(&mock, 10);
let body = client
.get("https://secure.local/to-http")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "plain");
assert_eq!(
mock.hops()
.into_iter()
.map(|hop| hop.url_scheme)
.collect::<Vec<_>>(),
vec![Some("https".to_owned()), Some("http".to_owned())]
);
}
#[cfg(feature = "__tls")]
#[tokio::test]
async fn redirect_updates_request_scheme_from_http_to_https() {
let mock = RedirectMock::default();
let client = mock_client_with_host(&mock, 10);
let body = client
.get("http://plain.local/to-https")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "secure");
assert_eq!(
mock.hops()
.into_iter()
.map(|hop| hop.url_scheme)
.collect::<Vec<_>>(),
vec![Some("http".to_owned()), Some("https".to_owned())]
);
}
#[cfg(feature = "http1")]
mod http_proxy_redirect_tests {
use super::*;
use crate::client::layer::http_proxy::HttpProxy;
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProxyHop {
dial_target: String,
uri: String,
host: Option<String>,
authorization: Option<String>,
proxy_authorization: Option<String>,
cookie: Option<String>,
}
#[derive(Clone, Default)]
struct ProxyRedirectMock {
hops: Arc<Mutex<Vec<ProxyHop>>>,
}
impl ProxyRedirectMock {
fn hops(&self) -> Vec<ProxyHop> {
self.hops.lock().unwrap().clone()
}
}
impl Service<ClientContext, Request> for ProxyRedirectMock {
type Response = Response;
type Error = ClientError;
async fn call(
&self,
cx: &mut ClientContext,
req: Request,
) -> Result<Self::Response, Self::Error> {
let dial_target = cx.target().to_string();
let uri = req.uri().to_string();
let upstream_host = req.uri().host().map(str::to_owned);
let path = req.uri().path().to_owned();
let host = req
.headers()
.get(header::HOST)
.map(|value| value.to_str().unwrap().to_owned());
let authorization = req
.headers()
.get(header::AUTHORIZATION)
.map(|value| value.to_str().unwrap().to_owned());
let proxy_authorization = req
.headers()
.get(header::PROXY_AUTHORIZATION)
.map(|value| value.to_str().unwrap().to_owned());
let cookie = req
.headers()
.get(header::COOKIE)
.map(|value| value.to_str().unwrap().to_owned());
self.hops.lock().unwrap().push(ProxyHop {
dial_target,
uri,
host,
authorization,
proxy_authorization,
cookie,
});
Ok(match (upstream_host.as_deref(), path.as_str()) {
(Some("first.local"), "/relative") => redirect_to("final", StatusCode::FOUND),
(Some("first.local"), "/final") => ok_with("relative-via-proxy"),
(Some("first.local"), "/cross") => {
redirect_to("http://second.local/landing", StatusCode::FOUND)
}
(Some("second.local"), "/landing") => ok_with("cross-via-proxy"),
(Some("first.local"), "/to-https") => {
redirect_to("https://secure.local/final", StatusCode::FOUND)
}
_ => ok_with("unexpected"),
})
}
}
fn proxy_then_redirect_client(mock: &ProxyRedirectMock) -> Client {
ClientBuilder::new()
.layer_outer(HttpProxy::new("http://proxy.local:8080"))
.follow_redirects(10)
.layer_outer_front(Host::Auto)
.mock(MockTransport::service(mock.clone()))
.unwrap()
}
fn redirect_then_proxy_client(mock: &ProxyRedirectMock) -> Client {
ClientBuilder::new()
.follow_redirects(10)
.layer_outer(HttpProxy::new("http://proxy.local:8080"))
.layer_outer_front(Host::Auto)
.mock(MockTransport::service(mock.clone()))
.unwrap()
}
async fn assert_relative_redirect_via_proxy(client: Client, mock: &ProxyRedirectMock) {
let body = client
.get("http://first.local/relative")
.header(header::PROXY_AUTHORIZATION, "Basic proxy")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "relative-via-proxy");
assert_eq!(
mock.hops(),
vec![
ProxyHop {
dial_target: "http://proxy.local:8080".to_owned(),
uri: "http://first.local/relative".to_owned(),
host: Some("first.local".to_owned()),
authorization: None,
proxy_authorization: Some("Basic proxy".to_owned()),
cookie: None,
},
ProxyHop {
dial_target: "http://proxy.local:8080".to_owned(),
uri: "http://first.local/final".to_owned(),
host: Some("first.local".to_owned()),
authorization: None,
proxy_authorization: Some("Basic proxy".to_owned()),
cookie: None,
},
]
);
}
#[tokio::test]
async fn relative_redirect_uses_proxy_when_proxy_is_added_first() {
let mock = ProxyRedirectMock::default();
assert_relative_redirect_via_proxy(proxy_then_redirect_client(&mock), &mock).await;
}
#[tokio::test]
async fn relative_redirect_uses_proxy_when_redirect_is_added_first() {
let mock = ProxyRedirectMock::default();
assert_relative_redirect_via_proxy(redirect_then_proxy_client(&mock), &mock).await;
}
#[tokio::test]
async fn cross_origin_redirect_via_proxy_strips_origin_credentials() {
let mock = ProxyRedirectMock::default();
let client = proxy_then_redirect_client(&mock);
let body = client
.get("http://first.local/cross")
.header(header::AUTHORIZATION, "Bearer first")
.header(header::PROXY_AUTHORIZATION, "Basic proxy")
.header(header::COOKIE, "session=first")
.send()
.await
.unwrap()
.into_string()
.await
.unwrap();
assert_eq!(body, "cross-via-proxy");
let hops = mock.hops();
assert_eq!(hops.len(), 2);
assert_eq!(hops[0].dial_target, "http://proxy.local:8080");
assert_eq!(hops[0].uri, "http://first.local/cross");
assert_eq!(hops[0].authorization.as_deref(), Some("Bearer first"));
assert_eq!(hops[0].cookie.as_deref(), Some("session=first"));
assert_eq!(hops[1].dial_target, "http://proxy.local:8080");
assert_eq!(hops[1].uri, "http://second.local/landing");
assert_eq!(hops[1].host.as_deref(), Some("second.local"));
assert_eq!(hops[1].authorization, None);
assert_eq!(hops[1].cookie, None);
assert_eq!(hops[1].proxy_authorization.as_deref(), Some("Basic proxy"));
}
#[cfg(feature = "__tls")]
#[tokio::test]
async fn redirect_to_https_preserves_direct_fallback() {
let mock = ProxyRedirectMock::default();
let client = proxy_then_redirect_client(&mock);
client
.get("http://first.local/to-https")
.send()
.await
.unwrap();
let hops = mock.hops();
assert_eq!(hops.len(), 2);
assert_eq!(hops[0].dial_target, "http://proxy.local:8080");
assert_eq!(hops[0].uri, "http://first.local/to-https");
assert_eq!(hops[1].dial_target, "https://secure.local");
assert_eq!(hops[1].uri, "/final");
assert_eq!(hops[1].host.as_deref(), Some("secure.local"));
}
}
}