Skip to main content

volo_http/client/layer/
redirect.rs

1use http::{
2    HeaderMap, Method, StatusCode,
3    header::{AUTHORIZATION, COOKIE, HOST, HeaderValue, LOCATION},
4    request::Parts,
5    uri::{PathAndQuery, Uri},
6};
7use motore::{layer::Layer, service::Service};
8use volo::{client::Apply, context::Context};
9
10use crate::{
11    body::Body,
12    client::{Target, target::RemoteHost},
13    context::ClientContext,
14    error::{
15        ClientError,
16        client::{Result, body_not_replayable, invalid_redirect_location, too_many_redirects},
17    },
18    request::Request,
19    response::Response,
20};
21
22const DEFAULT_MAX_REDIRECTS: usize = 10;
23
24/// Predicate used by [`FollowRedirect`] to decide whether a request should enter redirect
25/// following.
26///
27/// The predicate is checked before the initial request and before every follow-up redirect hop.
28/// If it returns `false` for the initial request, the layer directly passes the request to the
29/// inner service without cloning the request head for replay.
30pub trait RedirectPredicate: Clone + Send + Sync + 'static {
31    /// Return `true` if redirects should be followed for this request target and request head.
32    fn should_follow(
33        &self,
34        target: &Target,
35        method: &Method,
36        uri: &Uri,
37        headers: &HeaderMap,
38    ) -> bool;
39}
40
41/// [`RedirectPredicate`] that follows redirects for every request.
42#[derive(Clone, Copy, Debug, Default)]
43pub struct AlwaysRedirect;
44
45impl RedirectPredicate for AlwaysRedirect {
46    fn should_follow(&self, _: &Target, _: &Method, _: &Uri, _: &HeaderMap) -> bool {
47        true
48    }
49}
50
51impl<F> RedirectPredicate for F
52where
53    F: Fn(&Target, &Method, &Uri, &HeaderMap) -> bool + Clone + Send + Sync + 'static,
54{
55    fn should_follow(
56        &self,
57        target: &Target,
58        method: &Method,
59        uri: &Uri,
60        headers: &HeaderMap,
61    ) -> bool {
62        self(target, method, uri, headers)
63    }
64}
65
66/// [`Layer`] for following HTTP redirect responses.
67///
68/// This layer should be added as an outer client layer so that each redirected target is applied
69/// before service discovery and load balancing run for the next hop.
70///
71/// Each redirect hop is sent as a new request to the redirected target. Origin-specific headers
72/// generated by the client, including `Host`, are updated for the next hop instead of preserving a
73/// client-level `Host` override across the whole redirect chain.
74///
75/// Only in-memory request bodies can be replayed. If a redirect would require resending a
76/// non-cloneable streaming body, the layer returns a client error.
77///
78/// When combined with [`HttpProxy`](super::http_proxy::HttpProxy), this layer must wrap the
79/// proxy layer so every follow-up hop re-enters the proxy. Prefer
80/// [`ClientBuilder::follow_redirects`](crate::client::ClientBuilder::follow_redirects), which
81/// preserves this order for normally appended outer layers.
82#[derive(Clone, Copy, Debug)]
83pub struct FollowRedirect<P = AlwaysRedirect> {
84    max_redirects: usize,
85    predicate: P,
86}
87
88impl FollowRedirect<AlwaysRedirect> {
89    /// Create a [`FollowRedirect`] layer that follows up to `max_redirects` redirects.
90    ///
91    /// `0` disables redirect following and returns redirect responses as-is.
92    pub const fn new(max_redirects: usize) -> Self {
93        Self {
94            max_redirects,
95            predicate: AlwaysRedirect,
96        }
97    }
98}
99
100impl<P> FollowRedirect<P> {
101    /// Set a predicate that decides which requests should enter redirect following.
102    ///
103    /// Requests that do not match the predicate are passed through without cloning their request
104    /// head for possible redirect replay. The predicate is also checked before each follow-up hop;
105    /// if a redirect points to a target the predicate rejects, the redirect response is returned
106    /// as-is.
107    pub fn when<Next>(self, predicate: Next) -> FollowRedirect<Next>
108    where
109        Next: RedirectPredicate,
110    {
111        FollowRedirect {
112            max_redirects: self.max_redirects,
113            predicate,
114        }
115    }
116
117    /// Return the maximum number of redirects this layer will follow.
118    pub const fn max_redirects(&self) -> usize {
119        self.max_redirects
120    }
121}
122
123impl Default for FollowRedirect<AlwaysRedirect> {
124    fn default() -> Self {
125        Self::new(DEFAULT_MAX_REDIRECTS)
126    }
127}
128
129impl<S, P> Layer<S> for FollowRedirect<P>
130where
131    P: RedirectPredicate,
132{
133    type Service = FollowRedirectService<S, P>;
134
135    fn layer(self, inner: S) -> Self::Service {
136        FollowRedirectService {
137            inner,
138            max_redirects: self.max_redirects,
139            predicate: self.predicate,
140        }
141    }
142}
143
144/// The [`Service`] generated by [`FollowRedirect`].
145///
146/// See [`FollowRedirect`] for more details.
147pub struct FollowRedirectService<S, P = AlwaysRedirect> {
148    inner: S,
149    max_redirects: usize,
150    predicate: P,
151}
152
153impl<S, P, B, RespBody> Service<ClientContext, Request<B>> for FollowRedirectService<S, P>
154where
155    P: RedirectPredicate,
156    B: Into<Body> + Send,
157    S: Service<ClientContext, Request<Body>, Response = Response<RespBody>, Error = ClientError>
158        + Send
159        + Sync,
160{
161    type Response = Response<RespBody>;
162    type Error = ClientError;
163
164    async fn call(&self, cx: &mut ClientContext, req: Request<B>) -> Result<Self::Response> {
165        // Fast path: when redirects are disabled or the predicate rejects the initial request,
166        // pass it straight through without cloning the request head for possible replay.
167        if self.max_redirects == 0
168            || !self
169                .predicate
170                .should_follow(cx.target(), req.method(), req.uri(), req.headers())
171        {
172            let (parts, body) = req.into_parts();
173            return self
174                .inner
175                .call(cx, Request::from_parts(parts, body.into()))
176                .await;
177        }
178
179        let (mut parts, body) = req.into_parts();
180        let mut body: Body = body.into();
181        let mut redirects = 0usize;
182
183        loop {
184            let (this_body, can_replay_body) = match body.try_clone() {
185                Some(retained) => (std::mem::replace(&mut body, retained), true),
186                None => (std::mem::take(&mut body), false),
187            };
188
189            let req = Request::from_parts(parts.clone(), this_body);
190            let resp = self.inner.call(cx, req).await?;
191            let status = resp.status();
192
193            if !status_is_redirect(status) {
194                return Ok(resp);
195            }
196
197            if redirects >= self.max_redirects {
198                return Err(too_many_redirects());
199            }
200
201            let location = match resp.headers().get(LOCATION) {
202                Some(location) => location.clone(),
203                None => return Ok(resp),
204            };
205
206            let old_target = cx.target().clone();
207            let (target, uri) = resolve_redirect(&old_target, &parts.uri, &location)
208                .ok_or_else(invalid_redirect_location)?;
209
210            // Rewrite the request head into the next hop: apply the redirected URI, downgrade the
211            // method to GET for 303 (and 301/302 on POST), and strip credentials when the origin
212            // changes so they are not leaked to another host/scheme/port.
213            let changes_to_get = should_change_to_get(status, &parts.method);
214            if changes_to_get {
215                change_to_get(&mut parts);
216                body = Body::empty();
217            }
218            parts.uri = uri;
219            remove_sensitive_headers_on_origin_change(&mut parts, &old_target, &target);
220            update_host(&mut parts, &target);
221            super::utils::update_request_extension(&mut parts.extensions, &target);
222
223            // Decide whether this hop should actually be followed. Both checks happen before any
224            // mutation of `cx`, so a rejected hop returns the original response (or errors) without
225            // leaving the context pointing at the next target.
226            if !self
227                .predicate
228                .should_follow(&target, &parts.method, &parts.uri, &parts.headers)
229            {
230                return Ok(resp);
231            }
232            // A GET rewrite drops the body, so only a preserved body needs to be replayable.
233            if !changes_to_get && !can_replay_body {
234                return Err(body_not_replayable());
235            }
236
237            redirects += 1;
238            update_target(cx, target)?;
239        }
240    }
241}
242
243fn update_target(cx: &mut ClientContext, target: Target) -> Result<()> {
244    cx.rpc_info_mut().callee_mut().clear();
245    target.apply(cx)
246}
247
248fn update_host(parts: &mut Parts, target: &Target) {
249    if parts.headers.contains_key(HOST) {
250        parts.headers.remove(HOST);
251        if let Some(host) = super::header::gen_host(target) {
252            parts.headers.insert(HOST, host);
253        }
254    }
255}
256
257fn remove_sensitive_headers_on_origin_change(
258    parts: &mut Parts,
259    old_target: &Target,
260    new_target: &Target,
261) {
262    if origin_changed(old_target, new_target) {
263        parts.headers.remove(AUTHORIZATION);
264        parts.headers.remove(COOKIE);
265    }
266}
267
268fn origin_changed(old_target: &Target, new_target: &Target) -> bool {
269    match (old_target.remote_ref(), new_target.remote_ref()) {
270        (Some(old), Some(new)) => {
271            old.scheme != new.scheme
272                || old.port != new.port
273                || remote_host_changed(&old.host, &new.host)
274        }
275        _ => true,
276    }
277}
278
279fn remote_host_changed(old_host: &RemoteHost, new_host: &RemoteHost) -> bool {
280    match (old_host, new_host) {
281        (RemoteHost::Ip(old), RemoteHost::Ip(new)) => old != new,
282        (RemoteHost::Name(old), RemoteHost::Name(new)) => {
283            !old.as_str().eq_ignore_ascii_case(new.as_str())
284        }
285        _ => true,
286    }
287}
288
289fn change_to_get(parts: &mut Parts) {
290    parts.method = Method::GET;
291    parts.headers.remove(http::header::CONTENT_TYPE);
292    parts.headers.remove(http::header::CONTENT_LENGTH);
293    parts.headers.remove(http::header::CONTENT_ENCODING);
294    parts.headers.remove(http::header::TRANSFER_ENCODING);
295}
296
297fn status_is_redirect(status: StatusCode) -> bool {
298    matches!(
299        status,
300        StatusCode::MOVED_PERMANENTLY
301            | StatusCode::FOUND
302            | StatusCode::SEE_OTHER
303            | StatusCode::TEMPORARY_REDIRECT
304            | StatusCode::PERMANENT_REDIRECT
305    )
306}
307
308fn should_change_to_get(status: StatusCode, method: &Method) -> bool {
309    match status {
310        StatusCode::SEE_OTHER => method != Method::HEAD,
311        StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => method == Method::POST,
312        _ => false,
313    }
314}
315
316fn resolve_redirect(
317    target: &Target,
318    current_uri: &Uri,
319    location: &HeaderValue,
320) -> Option<(Target, Uri)> {
321    let location = location.to_str().ok()?;
322    let base = url::Url::parse(&format!("{target}{current_uri}")).ok()?;
323    let resolved = base.join(location).ok()?;
324
325    let uri: Uri = resolved.as_str().parse().ok()?;
326    let target = Target::from_uri(&uri).ok()?;
327    let path_and_query = uri
328        .path_and_query()
329        .map(PathAndQuery::to_owned)
330        .unwrap_or_else(|| PathAndQuery::from_static("/"))
331        .into();
332
333    Some((target, path_and_query))
334}
335
336#[cfg(test)]
337mod tests {
338    use std::sync::{Arc, Mutex};
339
340    use bytes::Bytes;
341    use http::{HeaderMap, Method, StatusCode, Uri, header};
342    use http_body::Frame;
343    use motore::service::Service;
344
345    use crate::{
346        ClientBuilder,
347        body::{Body, BodyConversion},
348        client::{Client, Target, layer::header::Host, test_helpers::MockTransport},
349        context::ClientContext,
350        error::ClientError,
351        request::{Request, RequestPartsExt},
352        response::Response,
353    };
354
355    #[derive(Clone, Debug, PartialEq, Eq)]
356    struct Hop {
357        target: String,
358        path: String,
359        host: Option<String>,
360        url_scheme: Option<String>,
361        authorization: Option<String>,
362        proxy_authorization: Option<String>,
363        cookie: Option<String>,
364        method: Method,
365        body: String,
366    }
367
368    #[derive(Clone, Default)]
369    struct RedirectMock {
370        hops: Arc<Mutex<Vec<Hop>>>,
371    }
372
373    impl RedirectMock {
374        fn hops(&self) -> Vec<Hop> {
375            self.hops.lock().unwrap().clone()
376        }
377    }
378
379    fn redirect_to(location: &str, status: StatusCode) -> Response {
380        let mut resp = Response::new(Body::empty());
381        *resp.status_mut() = status;
382        resp.headers_mut()
383            .insert(header::LOCATION, location.parse().unwrap());
384        resp
385    }
386
387    fn ok_with(body: &'static str) -> Response {
388        Response::new(Body::from(body))
389    }
390
391    impl Service<ClientContext, Request> for RedirectMock {
392        type Response = Response;
393        type Error = ClientError;
394
395        async fn call(
396            &self,
397            cx: &mut ClientContext,
398            req: Request,
399        ) -> Result<Self::Response, Self::Error> {
400            let target = cx.target().to_string();
401            let path = req.uri().path().to_owned();
402            let host = req
403                .headers()
404                .get(header::HOST)
405                .map(|value| value.to_str().unwrap().to_owned());
406            let authorization = req
407                .headers()
408                .get(header::AUTHORIZATION)
409                .map(|value| value.to_str().unwrap().to_owned());
410            let proxy_authorization = req
411                .headers()
412                .get(header::PROXY_AUTHORIZATION)
413                .map(|value| value.to_str().unwrap().to_owned());
414            let cookie = req
415                .headers()
416                .get(header::COOKIE)
417                .map(|value| value.to_str().unwrap().to_owned());
418            let method = req.method().clone();
419            let url_scheme = req.url().map(|url| url.scheme().to_owned());
420            let body = req.into_body().into_string().await.unwrap();
421
422            self.hops.lock().unwrap().push(Hop {
423                target: target.clone(),
424                path: path.clone(),
425                host,
426                url_scheme,
427                authorization,
428                proxy_authorization,
429                cookie,
430                method: method.clone(),
431                body: body.clone(),
432            });
433
434            let resp = match (target.as_str(), path.as_str()) {
435                ("http://first.local", "/cross") => {
436                    redirect_to("http://second.local/landing", StatusCode::FOUND)
437                }
438                ("http://second.local", "/landing") => ok_with("cross-host"),
439                ("https://secure.local", "/to-http") => {
440                    redirect_to("http://plain.local/final", StatusCode::FOUND)
441                }
442                ("http://plain.local", "/to-https") => {
443                    redirect_to("https://secure.local/final", StatusCode::FOUND)
444                }
445                ("http://plain.local", "/final") => ok_with("plain"),
446                ("https://secure.local", "/final") => ok_with("secure"),
447                (_, "/see-other") => redirect_to("/method", StatusCode::SEE_OTHER),
448                (_, "/temporary") => redirect_to("/method", StatusCode::TEMPORARY_REDIRECT),
449                (_, "/relative") => redirect_to("final", StatusCode::FOUND),
450                (_, "/final") => ok_with("relative"),
451                (_, "/method") => ok_with(if method == Method::GET && body.is_empty() {
452                    "GET:"
453                } else if method == Method::POST && body == "payload" {
454                    "POST:payload"
455                } else {
456                    "unexpected"
457                }),
458                (_, "/loop") => redirect_to("/loop", StatusCode::FOUND),
459                (_, "/no-location") => {
460                    let mut resp = Response::new(Body::empty());
461                    *resp.status_mut() = StatusCode::FOUND;
462                    resp
463                }
464                (_, "/bad-location") => redirect_to("ftp://example.com/next", StatusCode::FOUND),
465                _ => ok_with("default"),
466            };
467            Ok(resp)
468        }
469    }
470
471    #[tokio::test]
472    async fn follows_relative_location() {
473        let mock = RedirectMock::default();
474        let client = mock_client(&mock, 10);
475
476        let body = client
477            .get("http://host.local/relative")
478            .send()
479            .await
480            .unwrap()
481            .into_string()
482            .await
483            .unwrap();
484
485        assert_eq!(body, "relative");
486        assert_eq!(
487            mock.hops()
488                .into_iter()
489                .map(|hop| hop.path)
490                .collect::<Vec<_>>(),
491            vec!["/relative".to_owned(), "/final".to_owned()]
492        );
493    }
494
495    fn mock_client(mock: &RedirectMock, max_redirects: usize) -> Client {
496        ClientBuilder::new()
497            .follow_redirects(max_redirects)
498            .mock(MockTransport::service(mock.clone()))
499            .unwrap()
500    }
501
502    fn mock_client_with_host(mock: &RedirectMock, max_redirects: usize) -> Client {
503        ClientBuilder::new()
504            .follow_redirects(max_redirects)
505            .layer_outer_front(Host::Auto)
506            .mock(MockTransport::service(mock.clone()))
507            .unwrap()
508    }
509
510    fn predicate_client<P>(mock: &RedirectMock, predicate: P) -> Client
511    where
512        P: Fn(&Target, &Method, &Uri, &HeaderMap) -> bool + Clone + Send + Sync + 'static,
513    {
514        ClientBuilder::new()
515            .follow_redirects_when(10, predicate)
516            .mock(MockTransport::service(mock.clone()))
517            .unwrap()
518    }
519
520    #[tokio::test]
521    async fn predicate_skips_initial_request() {
522        let mock = RedirectMock::default();
523        let client = predicate_client(&mock, |_: &Target, _: &Method, uri: &Uri, _: &HeaderMap| {
524            uri.path() == "/allowed"
525        });
526
527        let resp = client
528            .get("http://host.local/relative")
529            .send()
530            .await
531            .unwrap();
532
533        assert_eq!(resp.status(), StatusCode::FOUND);
534        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "final");
535        assert_eq!(mock.hops().len(), 1);
536    }
537
538    #[tokio::test]
539    async fn predicate_stops_before_disallowed_next_hop() {
540        let mock = RedirectMock::default();
541        let client = predicate_client(
542            &mock,
543            |target: &Target, _: &Method, _: &Uri, _: &HeaderMap| {
544                target.to_string() == "http://first.local"
545            },
546        );
547
548        let resp = client.get("http://first.local/cross").send().await.unwrap();
549
550        assert_eq!(resp.status(), StatusCode::FOUND);
551        assert_eq!(
552            resp.headers().get(header::LOCATION).unwrap(),
553            "http://second.local/landing"
554        );
555        assert_eq!(mock.hops().len(), 1);
556    }
557
558    #[tokio::test]
559    async fn follows_cross_host_and_updates_host() {
560        let mock = RedirectMock::default();
561        let client = mock_client_with_host(&mock, 10);
562
563        let body = client
564            .get("http://first.local/cross")
565            .header(header::AUTHORIZATION, "Bearer first")
566            .header(header::PROXY_AUTHORIZATION, "Basic proxy")
567            .header(header::COOKIE, "session=first")
568            .send()
569            .await
570            .unwrap()
571            .into_string()
572            .await
573            .unwrap();
574
575        assert_eq!(body, "cross-host");
576        assert_eq!(
577            mock.hops(),
578            vec![
579                Hop {
580                    target: "http://first.local".to_owned(),
581                    path: "/cross".to_owned(),
582                    host: Some("first.local".to_owned()),
583                    url_scheme: Some("http".to_owned()),
584                    authorization: Some("Bearer first".to_owned()),
585                    proxy_authorization: Some("Basic proxy".to_owned()),
586                    cookie: Some("session=first".to_owned()),
587                    method: Method::GET,
588                    body: String::new(),
589                },
590                Hop {
591                    target: "http://second.local".to_owned(),
592                    path: "/landing".to_owned(),
593                    host: Some("second.local".to_owned()),
594                    url_scheme: Some("http".to_owned()),
595                    authorization: None,
596                    proxy_authorization: Some("Basic proxy".to_owned()),
597                    cookie: None,
598                    method: Method::GET,
599                    body: String::new(),
600                },
601            ]
602        );
603    }
604
605    #[tokio::test]
606    async fn see_other_switches_to_get_and_drops_body() {
607        let mock = RedirectMock::default();
608        let client = mock_client(&mock, 10);
609
610        let body = client
611            .post("http://host.local/see-other")
612            .data("payload")
613            .send()
614            .await
615            .unwrap()
616            .into_string()
617            .await
618            .unwrap();
619
620        assert_eq!(body, "GET:");
621    }
622
623    #[tokio::test]
624    async fn temporary_redirect_preserves_method_and_body() {
625        let mock = RedirectMock::default();
626        let client = mock_client(&mock, 10);
627
628        let body = client
629            .post("http://host.local/temporary")
630            .data("payload")
631            .send()
632            .await
633            .unwrap()
634            .into_string()
635            .await
636            .unwrap();
637
638        assert_eq!(body, "POST:payload");
639    }
640
641    #[tokio::test]
642    async fn streaming_body_redirect_errors() {
643        let mock = RedirectMock::default();
644        let client = mock_client(&mock, 10);
645        let stream = futures_util::stream::once(async {
646            Ok::<_, crate::error::BoxError>(Frame::data(Bytes::from_static(b"payload")))
647        });
648
649        let err = client
650            .post("http://host.local/temporary")
651            .body(Body::from_stream(stream))
652            .send()
653            .await
654            .unwrap_err();
655
656        assert!(
657            err.to_string()
658                .contains("request body is not replayable for redirect"),
659            "got: {err}"
660        );
661        assert_eq!(mock.hops().len(), 1);
662    }
663
664    #[tokio::test]
665    async fn zero_redirects_returns_redirect_response() {
666        let mock = RedirectMock::default();
667        let client = mock_client(&mock, 0);
668
669        let resp = client
670            .get("http://host.local/relative")
671            .send()
672            .await
673            .unwrap();
674
675        assert_eq!(resp.status(), StatusCode::FOUND);
676        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "final");
677        assert_eq!(mock.hops().len(), 1);
678    }
679
680    #[tokio::test]
681    async fn too_many_redirects_errors() {
682        let mock = RedirectMock::default();
683        let client = mock_client(&mock, 2);
684
685        let err = client
686            .get("http://host.local/loop")
687            .send()
688            .await
689            .unwrap_err();
690
691        assert!(err.to_string().contains("too many redirects"), "got: {err}");
692        assert_eq!(mock.hops().len(), 3);
693    }
694
695    #[tokio::test]
696    async fn missing_location_returns_redirect_response() {
697        let mock = RedirectMock::default();
698        let client = mock_client(&mock, 10);
699
700        let resp = client
701            .get("http://host.local/no-location")
702            .send()
703            .await
704            .unwrap();
705
706        assert_eq!(resp.status(), StatusCode::FOUND);
707        assert!(resp.headers().get(header::LOCATION).is_none());
708        assert_eq!(mock.hops().len(), 1);
709    }
710
711    #[tokio::test]
712    async fn invalid_location_errors() {
713        let mock = RedirectMock::default();
714        let client = mock_client(&mock, 10);
715
716        let err = client
717            .get("http://host.local/bad-location")
718            .send()
719            .await
720            .unwrap_err();
721
722        assert!(
723            err.to_string()
724                .contains("invalid Location header in redirect response"),
725            "got: {err}"
726        );
727        assert_eq!(mock.hops().len(), 1);
728    }
729
730    #[cfg(feature = "__tls")]
731    #[tokio::test]
732    async fn redirect_updates_request_scheme_from_https_to_http() {
733        let mock = RedirectMock::default();
734        let client = mock_client_with_host(&mock, 10);
735
736        let body = client
737            .get("https://secure.local/to-http")
738            .send()
739            .await
740            .unwrap()
741            .into_string()
742            .await
743            .unwrap();
744
745        assert_eq!(body, "plain");
746        assert_eq!(
747            mock.hops()
748                .into_iter()
749                .map(|hop| hop.url_scheme)
750                .collect::<Vec<_>>(),
751            vec![Some("https".to_owned()), Some("http".to_owned())]
752        );
753    }
754
755    #[cfg(feature = "__tls")]
756    #[tokio::test]
757    async fn redirect_updates_request_scheme_from_http_to_https() {
758        let mock = RedirectMock::default();
759        let client = mock_client_with_host(&mock, 10);
760
761        let body = client
762            .get("http://plain.local/to-https")
763            .send()
764            .await
765            .unwrap()
766            .into_string()
767            .await
768            .unwrap();
769
770        assert_eq!(body, "secure");
771        assert_eq!(
772            mock.hops()
773                .into_iter()
774                .map(|hop| hop.url_scheme)
775                .collect::<Vec<_>>(),
776            vec![Some("http".to_owned()), Some("https".to_owned())]
777        );
778    }
779
780    #[cfg(feature = "http1")]
781    mod http_proxy_redirect_tests {
782        use super::*;
783        use crate::client::layer::http_proxy::HttpProxy;
784
785        #[derive(Clone, Debug, PartialEq, Eq)]
786        struct ProxyHop {
787            dial_target: String,
788            uri: String,
789            host: Option<String>,
790            authorization: Option<String>,
791            proxy_authorization: Option<String>,
792            cookie: Option<String>,
793        }
794
795        #[derive(Clone, Default)]
796        struct ProxyRedirectMock {
797            hops: Arc<Mutex<Vec<ProxyHop>>>,
798        }
799
800        impl ProxyRedirectMock {
801            fn hops(&self) -> Vec<ProxyHop> {
802                self.hops.lock().unwrap().clone()
803            }
804        }
805
806        impl Service<ClientContext, Request> for ProxyRedirectMock {
807            type Response = Response;
808            type Error = ClientError;
809
810            async fn call(
811                &self,
812                cx: &mut ClientContext,
813                req: Request,
814            ) -> Result<Self::Response, Self::Error> {
815                let dial_target = cx.target().to_string();
816                let uri = req.uri().to_string();
817                let upstream_host = req.uri().host().map(str::to_owned);
818                let path = req.uri().path().to_owned();
819                let host = req
820                    .headers()
821                    .get(header::HOST)
822                    .map(|value| value.to_str().unwrap().to_owned());
823                let authorization = req
824                    .headers()
825                    .get(header::AUTHORIZATION)
826                    .map(|value| value.to_str().unwrap().to_owned());
827                let proxy_authorization = req
828                    .headers()
829                    .get(header::PROXY_AUTHORIZATION)
830                    .map(|value| value.to_str().unwrap().to_owned());
831                let cookie = req
832                    .headers()
833                    .get(header::COOKIE)
834                    .map(|value| value.to_str().unwrap().to_owned());
835
836                self.hops.lock().unwrap().push(ProxyHop {
837                    dial_target,
838                    uri,
839                    host,
840                    authorization,
841                    proxy_authorization,
842                    cookie,
843                });
844
845                Ok(match (upstream_host.as_deref(), path.as_str()) {
846                    (Some("first.local"), "/relative") => redirect_to("final", StatusCode::FOUND),
847                    (Some("first.local"), "/final") => ok_with("relative-via-proxy"),
848                    (Some("first.local"), "/cross") => {
849                        redirect_to("http://second.local/landing", StatusCode::FOUND)
850                    }
851                    (Some("second.local"), "/landing") => ok_with("cross-via-proxy"),
852                    (Some("first.local"), "/to-https") => {
853                        redirect_to("https://secure.local/final", StatusCode::FOUND)
854                    }
855                    _ => ok_with("unexpected"),
856                })
857            }
858        }
859
860        fn proxy_then_redirect_client(mock: &ProxyRedirectMock) -> Client {
861            ClientBuilder::new()
862                .layer_outer(HttpProxy::new("http://proxy.local:8080"))
863                .follow_redirects(10)
864                .layer_outer_front(Host::Auto)
865                .mock(MockTransport::service(mock.clone()))
866                .unwrap()
867        }
868
869        fn redirect_then_proxy_client(mock: &ProxyRedirectMock) -> Client {
870            ClientBuilder::new()
871                .follow_redirects(10)
872                .layer_outer(HttpProxy::new("http://proxy.local:8080"))
873                .layer_outer_front(Host::Auto)
874                .mock(MockTransport::service(mock.clone()))
875                .unwrap()
876        }
877
878        async fn assert_relative_redirect_via_proxy(client: Client, mock: &ProxyRedirectMock) {
879            let body = client
880                .get("http://first.local/relative")
881                .header(header::PROXY_AUTHORIZATION, "Basic proxy")
882                .send()
883                .await
884                .unwrap()
885                .into_string()
886                .await
887                .unwrap();
888
889            assert_eq!(body, "relative-via-proxy");
890            assert_eq!(
891                mock.hops(),
892                vec![
893                    ProxyHop {
894                        dial_target: "http://proxy.local:8080".to_owned(),
895                        uri: "http://first.local/relative".to_owned(),
896                        host: Some("first.local".to_owned()),
897                        authorization: None,
898                        proxy_authorization: Some("Basic proxy".to_owned()),
899                        cookie: None,
900                    },
901                    ProxyHop {
902                        dial_target: "http://proxy.local:8080".to_owned(),
903                        uri: "http://first.local/final".to_owned(),
904                        host: Some("first.local".to_owned()),
905                        authorization: None,
906                        proxy_authorization: Some("Basic proxy".to_owned()),
907                        cookie: None,
908                    },
909                ]
910            );
911        }
912
913        #[tokio::test]
914        async fn relative_redirect_uses_proxy_when_proxy_is_added_first() {
915            let mock = ProxyRedirectMock::default();
916            assert_relative_redirect_via_proxy(proxy_then_redirect_client(&mock), &mock).await;
917        }
918
919        #[tokio::test]
920        async fn relative_redirect_uses_proxy_when_redirect_is_added_first() {
921            let mock = ProxyRedirectMock::default();
922            assert_relative_redirect_via_proxy(redirect_then_proxy_client(&mock), &mock).await;
923        }
924
925        #[tokio::test]
926        async fn cross_origin_redirect_via_proxy_strips_origin_credentials() {
927            let mock = ProxyRedirectMock::default();
928            let client = proxy_then_redirect_client(&mock);
929
930            let body = client
931                .get("http://first.local/cross")
932                .header(header::AUTHORIZATION, "Bearer first")
933                .header(header::PROXY_AUTHORIZATION, "Basic proxy")
934                .header(header::COOKIE, "session=first")
935                .send()
936                .await
937                .unwrap()
938                .into_string()
939                .await
940                .unwrap();
941
942            assert_eq!(body, "cross-via-proxy");
943            let hops = mock.hops();
944            assert_eq!(hops.len(), 2);
945
946            assert_eq!(hops[0].dial_target, "http://proxy.local:8080");
947            assert_eq!(hops[0].uri, "http://first.local/cross");
948            assert_eq!(hops[0].authorization.as_deref(), Some("Bearer first"));
949            assert_eq!(hops[0].cookie.as_deref(), Some("session=first"));
950
951            assert_eq!(hops[1].dial_target, "http://proxy.local:8080");
952            assert_eq!(hops[1].uri, "http://second.local/landing");
953            assert_eq!(hops[1].host.as_deref(), Some("second.local"));
954            assert_eq!(hops[1].authorization, None);
955            assert_eq!(hops[1].cookie, None);
956
957            // Current conservative policy; see section 5.
958            assert_eq!(hops[1].proxy_authorization.as_deref(), Some("Basic proxy"));
959        }
960
961        #[cfg(feature = "__tls")]
962        #[tokio::test]
963        async fn redirect_to_https_preserves_direct_fallback() {
964            let mock = ProxyRedirectMock::default();
965            let client = proxy_then_redirect_client(&mock);
966
967            client
968                .get("http://first.local/to-https")
969                .send()
970                .await
971                .unwrap();
972
973            let hops = mock.hops();
974            assert_eq!(hops.len(), 2);
975
976            assert_eq!(hops[0].dial_target, "http://proxy.local:8080");
977            assert_eq!(hops[0].uri, "http://first.local/to-https");
978
979            assert_eq!(hops[1].dial_target, "https://secure.local");
980            assert_eq!(hops[1].uri, "/final");
981            assert_eq!(hops[1].host.as_deref(), Some("secure.local"));
982        }
983    }
984}