Skip to main content

http_client/
main.rs

1// Copyright 2019 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use anyhow::Context as _;
6use fidl::endpoints::ServerEnd;
7use fidl::prelude::*;
8use fidl_fuchsia_io as fio;
9use fidl_fuchsia_net_http as net_http;
10use fidl_fuchsia_pkg_http as fpkg_http;
11use fidl_fuchsia_process_lifecycle as flifecycle;
12use fuchsia_async::{self as fasync, TimeoutExt as _};
13use fuchsia_component::server::{Item, ServiceFs, ServiceFsDir};
14use fuchsia_hyper as fhyper;
15use fuchsia_inspect as finspect;
16use fuchsia_runtime::{HandleInfo, HandleType};
17use futures::StreamExt;
18use futures::future::Either;
19use futures::prelude::*;
20use http_client_config::Config;
21use hyper::header::{AUTHORIZATION, COOKIE, HeaderName, PROXY_AUTHORIZATION, WWW_AUTHENTICATE};
22use log::{debug, error, info, trace};
23use std::str::FromStr as _;
24
25mod pkg;
26mod resuming_get;
27
28static MAX_REDIRECTS: u8 = 10;
29static DEFAULT_DEADLINE_DURATION: zx::MonotonicDuration = zx::MonotonicDuration::from_seconds(15);
30
31fn to_status_line(version: hyper::Version, status: hyper::StatusCode) -> Vec<u8> {
32    match status.canonical_reason() {
33        None => format!("{:?} {}", version, status.as_str()),
34        Some(canonical_reason) => format!("{:?} {} {}", version, status.as_str(), canonical_reason),
35    }
36    .as_bytes()
37    .to_vec()
38}
39
40fn tcp_options() -> fhyper::TcpOptions {
41    let mut options: fhyper::TcpOptions = std::default::Default::default();
42
43    // Use TCP keepalive to notice stuck connections.
44    // After 60s with no data received send a probe every 15s.
45    options.keepalive_idle = Some(std::time::Duration::from_secs(60));
46    options.keepalive_interval = Some(std::time::Duration::from_secs(15));
47    // After 8 probes go unacknowledged treat the connection as dead.
48    options.keepalive_count = Some(8);
49
50    options
51}
52
53struct RedirectInfo {
54    url: Option<hyper::Uri>,
55    referrer: Option<hyper::Uri>,
56    method: hyper::Method,
57}
58
59fn redirect_info(
60    old_uri: &hyper::Uri,
61    method: &hyper::Method,
62    hyper_response: &hyper::Response<hyper::Body>,
63) -> Option<RedirectInfo> {
64    if hyper_response.status().is_redirection() {
65        Some(RedirectInfo {
66            url: hyper_response
67                .headers()
68                .get(hyper::header::LOCATION)
69                .and_then(|loc| calculate_redirect(old_uri, loc)),
70            referrer: hyper_response
71                .headers()
72                .get(hyper::header::REFERER)
73                .and_then(|loc| calculate_redirect(old_uri, loc)),
74            method: if hyper_response.status() == hyper::StatusCode::SEE_OTHER {
75                hyper::Method::GET
76            } else {
77                method.clone()
78            },
79        })
80    } else {
81        None
82    }
83}
84
85async fn to_success_response(
86    current_url: &hyper::Uri,
87    current_method: &hyper::Method,
88    mut hyper_response: hyper::Response<hyper::Body>,
89    scope: vfs::execution_scope::ExecutionScope,
90) -> net_http::Response {
91    let redirect_info = redirect_info(current_url, current_method, &hyper_response);
92    let headers = hyper_response
93        .headers()
94        .iter()
95        .map(|(name, value)| net_http::Header {
96            name: name.as_str().as_bytes().to_vec(),
97            value: value.as_bytes().to_vec(),
98        })
99        .collect();
100
101    let (tx, rx) = zx::Socket::create_stream();
102    let response = net_http::Response {
103        error: None,
104        body: Some(rx),
105        final_url: Some(current_url.to_string()),
106        status_code: Some(hyper_response.status().as_u16() as u32),
107        status_line: Some(to_status_line(hyper_response.version(), hyper_response.status())),
108        headers: Some(headers),
109        redirect: redirect_info.and_then(|info| {
110            info.url.map(|url| net_http::RedirectTarget {
111                method: Some(info.method.to_string()),
112                url: Some(url.to_string()),
113                referrer: info.referrer.map(|r| r.to_string()),
114                ..Default::default()
115            })
116        }),
117        ..Default::default()
118    };
119
120    let _ = scope.spawn(async move {
121        let hyper_body = hyper_response.body_mut();
122        while let Some(chunk) = hyper_body.next().await {
123            if let Ok(chunk) = chunk {
124                let mut offset: usize = 0;
125                while offset < chunk.len() {
126                    let pending = match tx.wait_one(
127                        zx::Signals::SOCKET_PEER_CLOSED | zx::Signals::SOCKET_WRITABLE,
128                        zx::MonotonicInstant::INFINITE,
129                    ).to_result() {
130                        Err(status) => {
131                            error!("tx.wait() failed - status: {}", status);
132                            return;
133                        }
134                        Ok(pending) => pending,
135                    };
136                    if pending.contains(zx::Signals::SOCKET_PEER_CLOSED) {
137                        info!("tx.wait() saw signal SOCKET_PEER_CLOSED");
138                        return;
139                    }
140                    assert!(pending.contains(zx::Signals::SOCKET_WRITABLE));
141                    let written = match tx.write(&chunk[offset..]) {
142                        Err(status) => {
143                            // Because of the wait above, we shouldn't ever see SHOULD_WAIT here, but to avoid
144                            // brittle-ness, continue and wait again in that case.
145                            if status == zx::Status::SHOULD_WAIT {
146                                error!("Saw SHOULD_WAIT despite waiting first - expected now? - continuing");
147                                continue;
148                            }
149                            info!("tx.write() failed - status: {}", status);
150                            return;
151                        }
152                        Ok(written) => written,
153                    };
154                    offset += written;
155                }
156            }
157        }
158    });
159
160    response
161}
162
163fn to_fidl_error(error: &hyper::Error) -> net_http::Error {
164    #[allow(clippy::if_same_then_else)] // TODO(https://fxbug.dev/42176989)
165    if error.is_parse() {
166        net_http::Error::UnableToParse
167    } else if error.is_user() {
168        //TODO(zmbush): handle this case.
169        net_http::Error::Internal
170    } else if error.is_canceled() {
171        //TODO(zmbush): handle this case.
172        net_http::Error::Internal
173    } else if error.is_closed() {
174        net_http::Error::ChannelClosed
175    } else if error.is_connect() {
176        net_http::Error::Connect
177    } else if error.is_incomplete_message() {
178        //TODO(zmbush): handle this case.
179        net_http::Error::Internal
180    } else if error.is_body_write_aborted() {
181        //TODO(zmbush): handle this case.
182        net_http::Error::Internal
183    } else {
184        net_http::Error::Internal
185    }
186}
187
188fn to_error_response(error: net_http::Error) -> net_http::Response {
189    net_http::Response {
190        error: Some(error),
191        body: None,
192        final_url: None,
193        status_code: None,
194        status_line: None,
195        headers: None,
196        redirect: None,
197        ..Default::default()
198    }
199}
200
201struct Loader {
202    method: hyper::Method,
203    url: hyper::Uri,
204    headers: hyper::HeaderMap,
205    body: Vec<u8>,
206    deadline: fasync::MonotonicInstant,
207    scope: vfs::execution_scope::ExecutionScope,
208}
209
210impl Loader {
211    async fn new(
212        req: net_http::Request,
213        scope: vfs::execution_scope::ExecutionScope,
214    ) -> Result<Self, anyhow::Error> {
215        let net_http::Request { method, url, headers, body, deadline, .. } = req;
216        let method = method.as_ref().map(|method| hyper::Method::from_str(method)).transpose()?;
217        let method = method.unwrap_or(hyper::Method::GET);
218        if let Some(url) = url {
219            let url = hyper::Uri::try_from(url)?;
220            let headers = headers
221                .unwrap_or_else(|| vec![])
222                .into_iter()
223                .map(|net_http::Header { name, value }| {
224                    let name = hyper::header::HeaderName::from_bytes(&name)?;
225                    let value = hyper::header::HeaderValue::from_bytes(&value)?;
226                    Ok((name, value))
227                })
228                .collect::<Result<hyper::HeaderMap, anyhow::Error>>()?;
229
230            let body = match body {
231                Some(net_http::Body::Buffer(buffer)) => {
232                    let mut bytes = vec![0; buffer.size as usize];
233                    buffer.vmo.read(&mut bytes, 0)?;
234                    bytes
235                }
236                Some(net_http::Body::Stream(socket)) => {
237                    let mut stream = fasync::Socket::from_socket(socket)
238                        .into_datagram_stream()
239                        .map(|r| r.context("reading from datagram stream"));
240                    let mut bytes = Vec::new();
241                    while let Some(chunk) = stream.next().await {
242                        bytes.extend(chunk?);
243                    }
244                    bytes
245                }
246                None => Vec::new(),
247            };
248
249            let deadline = deadline
250                .map(|deadline| fasync::MonotonicInstant::from_nanos(deadline))
251                .unwrap_or_else(|| fasync::MonotonicInstant::after(DEFAULT_DEADLINE_DURATION));
252
253            trace!("Starting request {} {}", method, url);
254
255            Ok(Loader { method, url, headers, body, deadline, scope })
256        } else {
257            Err(anyhow::Error::msg("Request missing URL"))
258        }
259    }
260
261    fn build_request(&self) -> hyper::Request<hyper::Body> {
262        let Self { method, url, headers, body, deadline: _, scope: _ } = self;
263        let mut request = hyper::Request::new(body.clone().into());
264        *request.method_mut() = method.clone();
265        *request.uri_mut() = url.clone();
266        *request.headers_mut() = headers.clone();
267        request
268    }
269
270    async fn start(mut self, loader_client: net_http::LoaderClientProxy) -> Result<(), zx::Status> {
271        let client = fhyper::new_https_client_from_tcp_options(tcp_options());
272        loop {
273            break match client.request(self.build_request()).await {
274                Ok(hyper_response) => {
275                    if let Some((url, method)) =
276                        handle_redirect(&self.url, &self.method, &hyper_response, &mut self.headers)
277                    {
278                        let response = to_success_response(
279                            &self.url,
280                            &self.method,
281                            hyper_response,
282                            self.scope.clone(),
283                        )
284                        .await;
285                        self.url = url;
286                        self.method = method;
287                        trace!("Reporting redirect to OnResponse: {} {}", self.method, self.url);
288                        match loader_client.on_response(response).await {
289                            Ok(()) => {}
290                            Err(e) => {
291                                debug!("Not redirecting because: {}", e);
292                                break Ok(());
293                            }
294                        };
295                        trace!("Redirect allowed to {} {}", self.method, self.url);
296                        continue;
297                    }
298                    let response = to_success_response(
299                        &self.url,
300                        &self.method,
301                        hyper_response,
302                        self.scope.clone(),
303                    )
304                    .await;
305                    // We don't care if on_response returns an error since this is the last
306                    // callback.
307                    let _: Result<_, _> = loader_client.on_response(response).await;
308                    Ok(())
309                }
310                Err(error) => {
311                    info!("Received network level error from hyper: {}", error);
312                    // We don't care if on_response returns an error since this is the last
313                    // callback.
314                    let _: Result<_, _> =
315                        loader_client.on_response(to_error_response(to_fidl_error(&error))).await;
316                    Ok(())
317                }
318            };
319        }
320    }
321
322    async fn fetch(
323        mut self,
324    ) -> Result<(hyper::Response<hyper::Body>, hyper::Uri, hyper::Method), net_http::Error> {
325        let deadline = self.deadline;
326        if deadline < fasync::MonotonicInstant::now() {
327            return Err(net_http::Error::DeadlineExceeded);
328        }
329        let client = fhyper::new_https_client_from_tcp_options(tcp_options());
330
331        async move {
332            let mut redirects = 0;
333            loop {
334                break match client.request(self.build_request()).await {
335                    Ok(hyper_response) => {
336                        if redirects != MAX_REDIRECTS {
337                            if let Some((url, method)) = handle_redirect(
338                                &self.url,
339                                &self.method,
340                                &hyper_response,
341                                &mut self.headers,
342                            ) {
343                                self.url = url;
344                                self.method = method;
345                                trace!("Redirecting to {} {}", self.method, self.url);
346                                redirects += 1;
347                                continue;
348                            }
349                        }
350                        Ok((hyper_response, self.url, self.method))
351                    }
352                    Err(e) => {
353                        info!("Received network level error from hyper: {}", e);
354                        Err(to_fidl_error(&e))
355                    }
356                };
357            }
358        }
359        .on_timeout(deadline, || Err(net_http::Error::DeadlineExceeded))
360        .await
361    }
362}
363
364fn calculate_redirect(
365    old_url: &hyper::Uri,
366    location: &hyper::header::HeaderValue,
367) -> Option<hyper::Uri> {
368    let old_parts = old_url.clone().into_parts();
369    let mut new_parts = hyper::Uri::try_from(location.as_bytes()).ok()?.into_parts();
370
371    // Prevent insecure redirect downgrade (https -> http)
372    if old_parts.scheme.as_ref().map(|s| s.as_str()) == Some("https")
373        && new_parts.scheme.as_ref().map(|s| s.as_str()) == Some("http")
374    {
375        error!("Not following insecure redirect downgrade");
376        return None;
377    }
378
379    if new_parts.scheme.is_none() {
380        new_parts.scheme = old_parts.scheme;
381    }
382    if new_parts.authority.is_none() {
383        new_parts.authority = old_parts.authority;
384    }
385    Some(hyper::Uri::from_parts(new_parts).ok()?)
386}
387
388// A request is considered cross-origin if the scheme or the authority differs
389// between the old and new url.
390fn is_cross_origin(old_url: &hyper::Uri, new_url: &hyper::Uri) -> bool {
391    old_url.scheme() != new_url.scheme() || old_url.authority() != new_url.authority()
392}
393
394fn sensitive_headers() -> [HeaderName; 5] {
395    [
396        AUTHORIZATION,
397        COOKIE,
398        HeaderName::from_static("cookie2"),
399        PROXY_AUTHORIZATION,
400        WWW_AUTHENTICATE,
401    ]
402}
403
404fn strip_sensitive_headers(headers: &mut hyper::HeaderMap) {
405    for header in sensitive_headers() {
406        let _ = headers.remove(header);
407    }
408}
409
410fn handle_redirect(
411    old_url: &hyper::Uri,
412    method: &hyper::Method,
413    hyper_response: &hyper::Response<hyper::Body>,
414    headers: &mut hyper::HeaderMap,
415) -> Option<(hyper::Uri, hyper::Method)> {
416    let redirect = redirect_info(old_url, method, hyper_response)?;
417    let url = redirect.url?;
418    if is_cross_origin(old_url, &url) {
419        strip_sensitive_headers(headers);
420    }
421    Some((url, redirect.method))
422}
423
424async fn loader_server(
425    stream: net_http::LoaderRequestStream,
426    idle_timeout: fasync::MonotonicDuration,
427) -> Result<(), anyhow::Error> {
428    let background_tasks = vfs::execution_scope::ExecutionScope::new();
429    let (stream, unbind_if_stalled) = detect_stall::until_stalled(stream, idle_timeout);
430
431    stream
432        .err_into::<anyhow::Error>()
433        .try_for_each_concurrent(None, |message| {
434            let scope = background_tasks.clone();
435            async move {
436                match message {
437                    net_http::LoaderRequest::Fetch { request, responder } => {
438                        debug!(
439                            "Fetch request received (url: {}): {:?}",
440                            request
441                                .url
442                                .as_ref()
443                                .and_then(|url| Some(url.as_str()))
444                                .unwrap_or_default(),
445                            request
446                        );
447                        let result = Loader::new(request, scope.clone()).await?.fetch().await;
448                        responder.send(match result {
449                            Ok((hyper_response, final_url, final_method)) => {
450                                to_success_response(
451                                    &final_url,
452                                    &final_method,
453                                    hyper_response,
454                                    scope.clone(),
455                                )
456                                .await
457                            }
458                            Err(error) => to_error_response(error),
459                        })?;
460                    }
461                    net_http::LoaderRequest::Start { request, client, control_handle } => {
462                        debug!(
463                            "Start request received (url: {}): {:?}",
464                            request
465                                .url
466                                .as_ref()
467                                .and_then(|url| Some(url.as_str()))
468                                .unwrap_or_default(),
469                            request
470                        );
471                        Loader::new(request, scope).await?.start(client.into_proxy()).await?;
472                        control_handle.shutdown();
473                    }
474                }
475                Ok(())
476            }
477        })
478        .await?;
479
480    background_tasks.wait().await;
481
482    // If the connection did not close or receive new messages within the timeout, send it
483    // over to component manager to wait for it on our behalf.
484    if let Ok(Some(server_end)) = unbind_if_stalled.await {
485        fuchsia_component::client::connect_channel_to_protocol_at::<net_http::LoaderMarker>(
486            server_end.into(),
487            "/escrow",
488        )?;
489    }
490
491    Ok(())
492}
493
494enum HttpServices {
495    Loader(net_http::LoaderRequestStream),
496    PkgClient(fpkg_http::ClientRequestStream),
497}
498
499#[fuchsia::main]
500pub async fn main() -> Result<(), anyhow::Error> {
501    log::info!("http-client starting");
502    fuchsia_trace_provider::trace_provider_create_with_fdio();
503    let inspector = finspect::Inspector::default();
504    let pkg_http_node = inspector.root().create_child("pkg-http");
505    let pkg_http_connections_node = pkg_http_node.create_child("connections");
506    let pkg_http_connection_count = std::sync::atomic::AtomicU64::new(0);
507    let _inspect_server_task =
508        inspect_runtime::publish(&inspector, inspect_runtime::PublishOptions::default());
509
510    // TODO(https://fxbug.dev/333080598): This is quite some boilerplate to escrow the outgoing dir.
511    // Design some library function to handle the lifecycle requests.
512    let lifecycle =
513        fuchsia_runtime::take_startup_handle(HandleInfo::new(HandleType::Lifecycle, 0)).unwrap();
514    let lifecycle: zx::Channel = lifecycle.into();
515    let lifecycle: ServerEnd<flifecycle::LifecycleMarker> = lifecycle.into();
516    let (mut lifecycle_request_stream, lifecycle_control_handle) =
517        lifecycle.into_stream_and_control_handle();
518
519    let config = Config::take_from_startup_handle();
520    let idle_timeout = if config.stop_on_idle_timeout_millis >= 0 {
521        fasync::MonotonicDuration::from_millis(config.stop_on_idle_timeout_millis)
522    } else {
523        fasync::MonotonicDuration::INFINITE
524    };
525
526    let mut fs = ServiceFs::new();
527    let _: &mut ServiceFsDir<'_, _> = fs
528        .take_and_serve_directory_handle()?
529        .dir("svc")
530        .add_fidl_service(HttpServices::Loader)
531        .add_fidl_service(HttpServices::PkgClient);
532
533    let lifecycle_task = async move {
534        let Some(Ok(request)) = lifecycle_request_stream.next().await else {
535            return std::future::pending::<()>().await;
536        };
537        match request {
538            flifecycle::LifecycleRequest::Stop { .. } => {
539                // TODO(https://fxbug.dev/332341289): If the framework asks us to stop, we still
540                // end up dropping requests. If we teach the `ServiceFs` etc. libraries to skip
541                // the timeout when this happens, we can cleanly stop the component.
542                return;
543            }
544        }
545    };
546
547    let outgoing_dir_task = async move {
548        fs.until_stalled(idle_timeout)
549            .for_each_concurrent(None, |item| async {
550                match item {
551                    Item::Request(services, _active_guard) => match services {
552                        HttpServices::Loader(stream) => loader_server(stream, idle_timeout)
553                            .await
554                            .unwrap_or_else(|e: anyhow::Error| error!("{:?}", e)),
555                        HttpServices::PkgClient(stream) => pkg::serve_client_request_stream(
556                            stream,
557                            idle_timeout,
558                            pkg_http_connections_node.create_child(
559                                pkg_http_connection_count
560                                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
561                                    .to_string(),
562                            ),
563                        )
564                        .await
565                        .unwrap_or_else(|e: anyhow::Error| error!("{e:#}")),
566                    },
567                    Item::Stalled(outgoing_directory) => {
568                        escrow_outgoing(lifecycle_control_handle.clone(), outgoing_directory.into())
569                    }
570                }
571            })
572            .await;
573    };
574
575    match futures::future::select(lifecycle_task.boxed_local(), outgoing_dir_task.boxed_local())
576        .await
577    {
578        Either::Left(_) => log::info!("http-client stopping because we are told to stop"),
579        Either::Right(_) => log::info!("http-client stopping because it is idle"),
580    }
581
582    Ok(())
583}
584
585/// Escrow the outgoing directory server endpoint to component manager, such that we will receive
586/// the same server endpoint on the next execution.
587fn escrow_outgoing(
588    lifecycle_control_handle: flifecycle::LifecycleControlHandle,
589    outgoing_dir: ServerEnd<fio::DirectoryMarker>,
590) {
591    let outgoing_dir = Some(outgoing_dir);
592    lifecycle_control_handle
593        .send_on_escrow(flifecycle::LifecycleOnEscrowRequest { outgoing_dir, ..Default::default() })
594        .unwrap();
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use hyper::header::{CONTENT_TYPE, HeaderMap, HeaderValue, LOCATION};
601
602    #[test]
603    fn test_is_cross_origin() {
604        let origin = hyper::Uri::from_static("https://example.com/path");
605
606        // Same origin, different path = same origin
607        assert!(!is_cross_origin(&origin, &hyper::Uri::from_static("https://example.com/other")));
608
609        // Same origin, same path with query = same origin
610        assert!(!is_cross_origin(
611            &origin,
612            &hyper::Uri::from_static("https://example.com/path?foo=bar")
613        ));
614
615        // Different host = cross-origin
616        assert!(is_cross_origin(&origin, &hyper::Uri::from_static("https://test.com/path")));
617
618        // Different scheme = cross-origin
619        assert!(is_cross_origin(&origin, &hyper::Uri::from_static("http://example.com/path")));
620
621        // Different port = cross-origin
622        assert!(is_cross_origin(
623            &origin,
624            &hyper::Uri::from_static("https://example.com:8080/path")
625        ));
626    }
627
628    #[test]
629    fn test_strip_sensitive_headers() {
630        let mut headers = HeaderMap::new();
631        let (content_label, content_value) =
632            (CONTENT_TYPE, HeaderValue::from_static("application/json"));
633        assert!(!headers.append(&content_label, content_value.clone()));
634
635        for header in sensitive_headers() {
636            assert!(!headers.append(header, HeaderValue::from_static("value")));
637        }
638
639        strip_sensitive_headers(&mut headers);
640
641        let mut expected_headers = HeaderMap::new();
642        assert!(!expected_headers.append(content_label, content_value));
643        assert_eq!(headers, expected_headers);
644    }
645
646    #[test]
647    fn test_strip_sensitive_headers_multiple_values() {
648        let mut headers = HeaderMap::new();
649        assert!(!headers.append(COOKIE, HeaderValue::from_static("session1=123"),));
650        // Append will return true and add the header value to the list of
651        // values for COOKIE.
652        assert!(headers.append(COOKIE, HeaderValue::from_static("session2=456"),));
653
654        strip_sensitive_headers(&mut headers);
655
656        assert!(!headers.contains_key(COOKIE));
657    }
658
659    fn run_redirect_test(
660        redirect_url: &'static str,
661        initial_headers: &[(hyper::header::HeaderName, &'static str)],
662        expected_url: &'static str,
663    ) -> hyper::HeaderMap {
664        let old_url = hyper::Uri::from_static("https://example.com/path");
665        let method = hyper::Method::GET;
666
667        let mut response = hyper::Response::new(hyper::Body::empty());
668        *response.status_mut() = hyper::StatusCode::MOVED_PERMANENTLY;
669        assert!(!response.headers_mut().append(LOCATION, HeaderValue::from_static(redirect_url),));
670
671        let mut headers = HeaderMap::new();
672        for (name, val) in initial_headers {
673            assert!(!headers.append(name, HeaderValue::from_static(val)));
674        }
675
676        let result = handle_redirect(&old_url, &method, &response, &mut headers);
677        assert_eq!(result, Some((hyper::Uri::from_static(expected_url), hyper::Method::GET)));
678        headers
679    }
680
681    #[test]
682    fn test_handle_redirect_same_origin() {
683        let (auth_key, auth_val) = (AUTHORIZATION, "Bearer token");
684        let headers = run_redirect_test(
685            "/new-path",
686            &[(auth_key.clone(), auth_val)],
687            "https://example.com/new-path",
688        );
689        // Headers must be preserved on same-origin redirect
690        assert_eq!(headers.get(&auth_key), Some(&HeaderValue::from_static(auth_val)));
691    }
692
693    #[test]
694    fn test_handle_redirect_cross_origin() {
695        let (auth_key, auth_val) = (AUTHORIZATION, "Bearer token");
696        let (content_type_key, content_type_val) = (CONTENT_TYPE, "application/json");
697        let headers = run_redirect_test(
698            "https://other.com/new-path",
699            &[(auth_key.clone(), auth_val), (content_type_key.clone(), content_type_val)],
700            "https://other.com/new-path",
701        );
702        // Authorization must be stripped on cross-origin redirect
703        assert!(!headers.contains_key(&auth_key));
704        // Content-Type must be preserved
705        assert_eq!(
706            headers.get(&content_type_key),
707            Some(&HeaderValue::from_static(content_type_val))
708        );
709    }
710
711    #[test]
712    fn test_calculate_redirect() {
713        let old_url = hyper::Uri::from_static("https://example.com/path");
714
715        // Same scheme, relative path = Perform redirect
716        let loc = hyper::header::HeaderValue::from_static("/new-path");
717        assert_eq!(
718            calculate_redirect(&old_url, &loc),
719            Some(hyper::Uri::from_static("https://example.com/new-path"))
720        );
721
722        // Same scheme, different host under example namespace = Perform redirect
723        let loc = hyper::header::HeaderValue::from_static("https://other.example.com/path");
724        assert_eq!(
725            calculate_redirect(&old_url, &loc),
726            Some(hyper::Uri::from_static("https://other.example.com/path"))
727        );
728
729        // Insecure redirect downgrade (https -> http) = Block redirect
730        let loc = hyper::header::HeaderValue::from_static("http://example.com/path");
731        assert_eq!(calculate_redirect(&old_url, &loc), None);
732
733        // Insecure to insecure redirect = Perform redirect
734        let old_url_http = hyper::Uri::from_static("http://example.com/path");
735        let loc = hyper::header::HeaderValue::from_static("http://other.example.com/path");
736        assert_eq!(
737            calculate_redirect(&old_url_http, &loc),
738            Some(hyper::Uri::from_static("http://other.example.com/path"))
739        );
740
741        // Insecure redirect downgrade with uppercase scheme (https -> HTTP) = Block redirect
742        let loc = hyper::header::HeaderValue::from_static("HTTP://example.com/path");
743        assert_eq!(calculate_redirect(&old_url, &loc), None);
744
745        // Invalid URL characters = Block redirect
746        let loc = hyper::header::HeaderValue::from_bytes(b"https://\xffinvalid.com").unwrap();
747        assert_eq!(calculate_redirect(&old_url, &loc), None);
748    }
749}