Skip to main content

aws_smithy_http_client/client/tls/
rustls_provider.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5use crate::client::tls::Provider;
6use rustls::crypto::CryptoProvider;
7
8/// Choice of underlying cryptography library (this only applies to rustls)
9#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub enum CryptoMode {
12    /// Crypto based on [ring](https://github.com/briansmith/ring)
13    #[cfg(feature = "rustls-ring")]
14    Ring,
15    /// Crypto based on [aws-lc](https://github.com/aws/aws-lc-rs)
16    #[cfg(feature = "rustls-aws-lc")]
17    AwsLc,
18    /// FIPS compliant variant of [aws-lc](https://github.com/aws/aws-lc-rs)
19    #[cfg(feature = "rustls-aws-lc-fips")]
20    AwsLcFips,
21    /// Use a caller-supplied [`CryptoProvider`].
22    ///
23    /// Unlike the built-in modes, the cipher-suite restriction normally
24    /// applied by smithy-rs is skipped -- the caller is expected
25    /// to select the applicable cipher suites via the supplied provider.
26    ///
27    /// This variant is provided behind an `aws_sdk_unstable` cfg flag,
28    /// because the version of rustls may change in the future,
29    #[cfg(all(aws_sdk_unstable, feature = "__rustls"))]
30    Custom(CryptoProvider),
31}
32
33impl std::cmp::PartialEq for CryptoMode {
34    fn eq(&self, other: &CryptoMode) -> bool {
35        match (self, other) {
36            #[cfg(feature = "rustls-ring")]
37            (Self::Ring, Self::Ring) => true,
38            #[cfg(feature = "rustls-aws-lc")]
39            (Self::AwsLc, Self::AwsLc) => true,
40            #[cfg(feature = "rustls-aws-lc-fips")]
41            (Self::AwsLcFips, Self::AwsLcFips) => true,
42            // `CryptoProvider` does not implement PartialEq, so any
43            // `CryptoMode::Custom` value will always compare not equal to
44            // any other.
45            #[allow(unreachable_patterns)]
46            _ => false,
47        }
48    }
49}
50
51#[cfg(not(all(aws_sdk_unstable, feature = "__rustls")))]
52impl Eq for CryptoMode {}
53
54impl CryptoMode {
55    fn provider(self) -> CryptoProvider {
56        match self {
57            #[cfg(feature = "rustls-aws-lc")]
58            CryptoMode::AwsLc => rustls::crypto::aws_lc_rs::default_provider(),
59
60            #[cfg(feature = "rustls-ring")]
61            CryptoMode::Ring => rustls::crypto::ring::default_provider(),
62
63            #[cfg(feature = "rustls-aws-lc-fips")]
64            CryptoMode::AwsLcFips => {
65                let provider = rustls::crypto::default_fips_provider();
66                assert!(
67                    provider.fips(),
68                    "FIPS was requested but the provider did not support FIPS"
69                );
70                provider
71            }
72            #[cfg(all(aws_sdk_unstable, feature = "__rustls"))]
73            CryptoMode::Custom(provider) => provider,
74        }
75    }
76
77    #[cfg(all(aws_sdk_unstable, feature = "__rustls"))]
78    fn is_custom(&self) -> bool {
79        matches!(self, Self::Custom(_))
80    }
81
82    #[cfg(not(all(aws_sdk_unstable, feature = "__rustls")))]
83    fn is_custom(&self) -> bool {
84        false
85    }
86}
87
88impl Provider {
89    /// Create a TLS provider based on [rustls](https://github.com/rustls/rustls)
90    /// and the given [`CryptoMode`]
91    pub fn rustls(mode: CryptoMode) -> Provider {
92        Provider::Rustls(mode)
93    }
94}
95
96pub(crate) mod build_connector {
97    use crate::client::tls::rustls_provider::CryptoMode;
98    use crate::tls::TlsContext;
99    use client::connect::HttpConnector;
100    use hyper_util::client::legacy as client;
101    use rustls::client::danger::ServerCertVerified;
102    use rustls::client::danger::ServerCertVerifier;
103    use rustls::client::WebPkiServerVerifier;
104    use rustls::crypto::CryptoProvider;
105    use rustls_native_certs::CertificateResult;
106    use rustls_pki_types::pem::PemObject;
107    use rustls_pki_types::CertificateDer;
108    use rustls_pki_types::ServerName as RustlsServerName;
109    use std::sync::Arc;
110    use std::sync::LazyLock;
111
112    /// Cached native certificates
113    ///
114    /// Creating a `with_native_roots()` hyper_rustls client re-loads system certs
115    /// each invocation (which can take 300ms on OSx). Cache the loaded certs
116    /// to avoid repeatedly incurring that cost.
117    pub(crate) static NATIVE_ROOTS: LazyLock<Vec<CertificateDer<'static>>> = LazyLock::new(|| {
118        let CertificateResult { certs, errors, .. } = rustls_native_certs::load_native_certs();
119        if !errors.is_empty() {
120            tracing::warn!("native root CA certificate loading errors: {errors:?}")
121        }
122
123        if certs.is_empty() {
124            tracing::warn!("no native root CA certificates found!");
125        }
126
127        // NOTE: unlike hyper-rustls::with_native_roots we don't validate here, we'll do that later
128        // for now we have a collection of certs that may or may not be valid.
129        certs
130    });
131
132    pub(crate) fn restrict_ciphers(base: CryptoProvider) -> CryptoProvider {
133        let suites = &[
134            rustls::CipherSuite::TLS13_AES_256_GCM_SHA384,
135            rustls::CipherSuite::TLS13_AES_128_GCM_SHA256,
136            // TLS1.2 suites
137            rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
138            rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
139            rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
140            rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
141            rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
142        ];
143        let supported_suites = suites
144            .iter()
145            .flat_map(|suite| {
146                base.cipher_suites
147                    .iter()
148                    .find(|s| &s.suite() == suite)
149                    .cloned()
150            })
151            .collect::<Vec<_>>();
152        CryptoProvider {
153            cipher_suites: supported_suites,
154            ..base
155        }
156    }
157
158    impl TlsContext {
159        pub(crate) fn rustls_root_certs(&self) -> rustls::RootCertStore {
160            let mut roots = rustls::RootCertStore::empty();
161            if self.trust_store.enable_native_roots {
162                let (valid, _invalid) = roots.add_parsable_certificates(NATIVE_ROOTS.clone());
163                debug_assert!(valid > 0, "TrustStore configured to enable native roots but no valid root certificates parsed!");
164            }
165
166            for pem_cert in &self.trust_store.custom_certs {
167                let ders = CertificateDer::pem_slice_iter(&pem_cert.0)
168                    .collect::<Result<Vec<_>, _>>()
169                    .expect("valid PEM certificate");
170                for cert in ders {
171                    roots.add(cert).expect("cert parsable")
172                }
173            }
174
175            roots
176        }
177
178        fn additional_server_names(&self) -> Vec<RustlsServerName<'static>> {
179            self.additional_server_names
180                .iter()
181                .map(|name| name.0.clone())
182                .collect()
183        }
184    }
185
186    /// Create a rustls ClientConfig with smithy-rs defaults
187    ///
188    /// This centralizes the rustls ClientConfig creation logic to ensure
189    /// consistency between the main HTTPS connector and tunnel handlers.
190    pub(crate) fn create_rustls_client_config(
191        crypto_mode: CryptoMode,
192        tls_context: &TlsContext,
193    ) -> rustls::ClientConfig {
194        let skip_restrict = crypto_mode.is_custom();
195        let provider = Arc::new(if skip_restrict {
196            crypto_mode.provider()
197        } else {
198            restrict_ciphers(crypto_mode.provider())
199        });
200        let root_certs = tls_context.rustls_root_certs();
201        let additional_server_names = tls_context.additional_server_names();
202
203        let builder = rustls::ClientConfig::builder_with_provider(Arc::clone(&provider))
204            .with_safe_default_protocol_versions()
205            .expect("Error with the TLS configuration. Please file a bug report under https://github.com/smithy-lang/smithy-rs/issues.");
206
207        if additional_server_names.is_empty() {
208            builder
209                .with_root_certificates(root_certs)
210                .with_no_client_auth()
211        } else {
212            let web_pki_server_verifier = WebPkiServerVerifier::builder_with_provider(
213                Arc::new(root_certs),
214                Arc::clone(&provider),
215            )
216            .build()
217            .expect("at least one root certificate was provided as a trust anchor");
218
219            builder
220                .dangerous()
221                .with_custom_certificate_verifier(Arc::new(ServerVerifier {
222                    web_pki_server_verifier,
223                    additional_server_names,
224                }))
225                .with_no_client_auth()
226        }
227    }
228
229    /// A server certificate verifier that extends standard WebPKI verification with
230    /// support for additional server names.
231    ///
232    /// By default, rustls verifies the server's certificate against the hostname from
233    /// the request URI. This verifier first attempts standard verification against that
234    /// hostname, and if it fails, retries verification against each name in
235    /// [`additional_server_names`](crate::tls::TlsContext::additional_server_names).
236    /// This is useful when a server presents a certificate whose Subject Alternative
237    /// Names (SANs) do not include the hostname used to connect, but do include an
238    /// alternative name the client has been configured to accept.
239    ///
240    /// All other verification behavior (signature validation, trust chain resolution,
241    /// TLS 1.2/1.3 signature checks) is delegated to the inner [`WebPkiServerVerifier`].
242    #[derive(Debug)]
243    struct ServerVerifier {
244        web_pki_server_verifier: Arc<WebPkiServerVerifier>,
245        additional_server_names: Vec<RustlsServerName<'static>>,
246    }
247
248    impl ServerCertVerifier for ServerVerifier {
249        fn verify_server_cert(
250            &self,
251            end_entity: &CertificateDer<'_>,
252            intermediates: &[CertificateDer<'_>],
253            server_name: &RustlsServerName<'_>,
254            ocsp_response: &[u8],
255            now: rustls_pki_types::UnixTime,
256        ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
257            match self.web_pki_server_verifier.verify_server_cert(
258                end_entity,
259                intermediates,
260                server_name,
261                ocsp_response,
262                now,
263            ) {
264                Ok(server_cert_verified) => Ok(server_cert_verified),
265                Err(error) => {
266                    let matched = self.additional_server_names.iter().any(|alt_name| {
267                        self.web_pki_server_verifier
268                            .verify_server_cert(
269                                end_entity,
270                                intermediates,
271                                alt_name,
272                                ocsp_response,
273                                now,
274                            )
275                            .is_ok()
276                    });
277                    if matched {
278                        Ok(ServerCertVerified::assertion())
279                    } else {
280                        Err(error)
281                    }
282                }
283            }
284        }
285
286        fn verify_tls12_signature(
287            &self,
288            message: &[u8],
289            cert: &CertificateDer<'_>,
290            dss: &rustls::DigitallySignedStruct,
291        ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
292            self.web_pki_server_verifier
293                .verify_tls12_signature(message, cert, dss)
294        }
295
296        fn verify_tls13_signature(
297            &self,
298            message: &[u8],
299            cert: &CertificateDer<'_>,
300            dss: &rustls::DigitallySignedStruct,
301        ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
302            self.web_pki_server_verifier
303                .verify_tls13_signature(message, cert, dss)
304        }
305
306        fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
307            self.web_pki_server_verifier.supported_verify_schemes()
308        }
309    }
310
311    pub(crate) fn wrap_connector<R>(
312        conn: HttpConnector<R>,
313        crypto_mode: CryptoMode,
314        tls_context: &TlsContext,
315        proxy_config: crate::client::proxy::ProxyConfig,
316    ) -> super::connect::RustTlsConnector<R> {
317        wrap_connector_with_alpn(
318            conn,
319            crypto_mode,
320            tls_context,
321            proxy_config,
322            &[b"h2", b"http/1.1"],
323        )
324    }
325
326    /// Wraps an HTTP connector with the ALPN offer selected for one pool attempt.
327    pub(crate) fn wrap_connector_with_alpn<R>(
328        mut conn: HttpConnector<R>,
329        crypto_mode: CryptoMode,
330        tls_context: &TlsContext,
331        proxy_config: crate::client::proxy::ProxyConfig,
332        alpn_protocols: &[&[u8]],
333    ) -> super::connect::RustTlsConnector<R> {
334        let mut client_config = create_rustls_client_config(crypto_mode, tls_context);
335        client_config.alpn_protocols = alpn_protocols
336            .iter()
337            .map(|protocol| protocol.to_vec())
338            .collect();
339        conn.enforce_http(false);
340        let https_connector = hyper_rustls::HttpsConnector::from((conn, client_config.clone()));
341
342        super::connect::RustTlsConnector::new(https_connector, client_config, proxy_config)
343    }
344}
345
346pub(crate) mod connect {
347    use crate::client::connect::{Conn, ConnectPathInner, Connecting};
348    use crate::client::error_chain;
349    use crate::client::proxy::ProxyConfig;
350    use crate::client::tls::{TlsConnectError, TlsConnectErrorKind};
351    use aws_smithy_runtime_api::box_error::BoxError;
352    use http_1x::uri::Scheme;
353    use http_1x::Uri;
354    use hyper::rt::{Read, ReadBufCursor, Write};
355    use hyper_rustls::MaybeHttpsStream;
356    use hyper_util::client::legacy::connect::{Connected, Connection, HttpConnector};
357    use hyper_util::client::proxy::matcher::Matcher;
358    use hyper_util::rt::TokioIo;
359    use pin_project_lite::pin_project;
360    use std::error::Error;
361    use std::sync::Arc;
362    use std::{
363        io::{self, IoSlice},
364        pin::Pin,
365        task::{Context, Poll},
366    };
367    use tokio::io::{AsyncRead, AsyncWrite};
368    use tokio::net::TcpStream;
369    use tokio_rustls::client::TlsStream;
370    use tower::Service;
371
372    /// Tags a TLS handshake failure whose chain carries a `rustls::Error` as
373    /// [`TlsConnectErrorKind::Protocol`], and returns any other `error` unchanged.
374    ///
375    /// The chain is walked with [`error_chain`], which also visits each `io::Error`'s payload:
376    /// hyper-rustls nests the rustls error two `io::Error`s deep.
377    fn classify_rustls(error: BoxError) -> BoxError {
378        if error_chain(error.as_ref()).any(|error| error.is::<rustls::Error>()) {
379            Box::new(TlsConnectError::new(TlsConnectErrorKind::Protocol, error))
380        } else {
381            error
382        }
383    }
384
385    #[derive(Debug, Clone)]
386    pub(crate) struct RustTlsConnector<R> {
387        https: hyper_rustls::HttpsConnector<HttpConnector<R>>,
388        tls_config: Arc<rustls::ClientConfig>,
389        proxy_matcher: Option<Arc<Matcher>>, // Pre-computed for performance
390    }
391
392    impl<R> RustTlsConnector<R> {
393        pub(super) fn new(
394            https: hyper_rustls::HttpsConnector<HttpConnector<R>>,
395            tls_config: rustls::ClientConfig,
396            proxy_config: ProxyConfig,
397        ) -> Self {
398            // Pre-compute the proxy matcher once during construction
399            let proxy_matcher = if proxy_config.is_disabled() {
400                None
401            } else {
402                Some(Arc::new(proxy_config.into_hyper_util_matcher()))
403            };
404
405            Self {
406                https,
407                tls_config: Arc::new(tls_config),
408                proxy_matcher,
409            }
410        }
411    }
412
413    impl<R> Service<Uri> for RustTlsConnector<R>
414    where
415        R: Clone + Send + Sync + 'static,
416        R: Service<hyper_util::client::legacy::connect::dns::Name>,
417        R::Response: Iterator<Item = std::net::SocketAddr>,
418        R::Future: Send,
419        R::Error: Into<Box<dyn Error + Send + Sync>>,
420    {
421        type Response = Conn;
422        type Error = BoxError;
423        type Future = Connecting;
424
425        fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
426            self.https.poll_ready(cx).map_err(Into::into)
427        }
428
429        fn call(&mut self, dst: Uri) -> Self::Future {
430            // Check if this request should be proxied using pre-computed matcher
431            let proxy_intercept = if let Some(ref matcher) = self.proxy_matcher {
432                matcher.intercept(&dst)
433            } else {
434                None
435            };
436
437            if let Some(intercept) = proxy_intercept {
438                if dst.scheme() == Some(&Scheme::HTTPS) {
439                    // HTTPS through HTTP proxy: Use CONNECT tunneling + manual TLS
440                    self.handle_https_through_proxy(dst, intercept)
441                } else {
442                    // HTTP through proxy: Direct connection to proxy
443                    self.handle_http_through_proxy(dst, intercept)
444                }
445            } else {
446                // Direct connection: Use the existing HTTPS connector
447                self.handle_direct_connection(dst)
448            }
449        }
450    }
451
452    impl<R> RustTlsConnector<R>
453    where
454        R: Clone + Send + Sync + 'static,
455        R: Service<hyper_util::client::legacy::connect::dns::Name>,
456        R::Response: Iterator<Item = std::net::SocketAddr>,
457        R::Future: Send,
458        R::Error: Into<Box<dyn Error + Send + Sync>>,
459    {
460        fn handle_direct_connection(&mut self, dst: Uri) -> Connecting {
461            let fut = self.https.call(dst);
462            Box::pin(async move {
463                let conn = fut.await.map_err(classify_rustls)?;
464                Ok(Conn {
465                    inner: Box::new(conn),
466                    connect_path: ConnectPathInner::Direct,
467                })
468            })
469        }
470
471        fn handle_http_through_proxy(
472            &mut self,
473            _dst: Uri,
474            intercept: hyper_util::client::proxy::matcher::Intercept,
475        ) -> Connecting {
476            // For HTTP through proxy, connect to the proxy and let it handle the request
477            let proxy_uri = intercept.uri().clone();
478            let connect_path = ConnectPathInner::forward_proxy(intercept.basic_auth().cloned());
479            let fut = self.https.call(proxy_uri);
480            Box::pin(async move {
481                let conn = fut.await?;
482                Ok(Conn {
483                    inner: Box::new(conn),
484                    connect_path,
485                })
486            })
487        }
488
489        fn handle_https_through_proxy(
490            &mut self,
491            dst: Uri,
492            intercept: hyper_util::client::proxy::matcher::Intercept,
493        ) -> Connecting {
494            use rustls_pki_types::ServerName as RustlsServerName;
495            // For HTTPS through HTTP proxy, we need to:
496            // 1. Establish CONNECT tunnel using the HTTPS connector
497            // 2. Perform manual TLS handshake over the tunneled stream
498
499            let tunnel = hyper_util::client::legacy::connect::proxy::Tunnel::new(
500                intercept.uri().clone(),
501                self.https.clone(),
502            );
503
504            // Configure tunnel with authentication if present
505            let mut tunnel = if let Some(auth) = intercept.basic_auth() {
506                tunnel.with_auth(auth.clone())
507            } else {
508                tunnel
509            };
510
511            let tls_config = self.tls_config.clone();
512            let dst_clone = dst.clone();
513
514            Box::pin(async move {
515                // Establish CONNECT tunnel
516                tracing::trace!("tunneling HTTPS over proxy");
517                let tunneled = tunnel.call(dst_clone.clone()).await?;
518
519                // Stage 2: Manual TLS handshake over tunneled stream
520                let host = dst_clone
521                    .host()
522                    .ok_or("missing host in URI for TLS handshake")?;
523
524                let server_name = RustlsServerName::try_from(host.to_owned()).map_err(|e| {
525                    BoxError::from(format!("invalid server name for TLS handshake: {e}"))
526                })?;
527
528                let tls_connector = tokio_rustls::TlsConnector::from(tls_config)
529                    .connect(server_name, TokioIo::new(tunneled))
530                    .await
531                    .map_err(|error| classify_rustls(error.into()))?;
532
533                Ok(Conn {
534                    inner: Box::new(RustTlsConn {
535                        inner: TokioIo::new(tls_connector),
536                    }),
537                    connect_path: ConnectPathInner::ProxyTunnel,
538                })
539            })
540        }
541    }
542
543    pin_project! {
544        pub(crate) struct RustTlsConn<T> {
545            #[pin] pub(super) inner: TokioIo<TlsStream<T>>
546        }
547    }
548
549    impl Connection for RustTlsConn<TokioIo<TokioIo<TcpStream>>> {
550        fn connected(&self) -> Connected {
551            if self.inner.inner().get_ref().1.alpn_protocol() == Some(b"h2") {
552                self.inner
553                    .inner()
554                    .get_ref()
555                    .0
556                    .inner()
557                    .connected()
558                    .negotiated_h2()
559            } else {
560                self.inner.inner().get_ref().0.inner().connected()
561            }
562        }
563    }
564
565    impl Connection for RustTlsConn<TokioIo<MaybeHttpsStream<TokioIo<TcpStream>>>> {
566        fn connected(&self) -> Connected {
567            if self.inner.inner().get_ref().1.alpn_protocol() == Some(b"h2") {
568                self.inner
569                    .inner()
570                    .get_ref()
571                    .0
572                    .inner()
573                    .connected()
574                    .negotiated_h2()
575            } else {
576                self.inner.inner().get_ref().0.inner().connected()
577            }
578        }
579    }
580    impl<T: AsyncRead + AsyncWrite + Unpin> Read for RustTlsConn<T> {
581        fn poll_read(
582            self: Pin<&mut Self>,
583            cx: &mut Context<'_>,
584            buf: ReadBufCursor<'_>,
585        ) -> Poll<tokio::io::Result<()>> {
586            let this = self.project();
587            Read::poll_read(this.inner, cx, buf)
588        }
589    }
590
591    impl<T: AsyncRead + AsyncWrite + Unpin> Write for RustTlsConn<T> {
592        fn poll_write(
593            self: Pin<&mut Self>,
594            cx: &mut Context<'_>,
595            buf: &[u8],
596        ) -> Poll<Result<usize, tokio::io::Error>> {
597            let this = self.project();
598            Write::poll_write(this.inner, cx, buf)
599        }
600
601        fn poll_write_vectored(
602            self: Pin<&mut Self>,
603            cx: &mut Context<'_>,
604            bufs: &[IoSlice<'_>],
605        ) -> Poll<Result<usize, io::Error>> {
606            let this = self.project();
607            Write::poll_write_vectored(this.inner, cx, bufs)
608        }
609
610        fn is_write_vectored(&self) -> bool {
611            self.inner.is_write_vectored()
612        }
613
614        fn poll_flush(
615            self: Pin<&mut Self>,
616            cx: &mut Context<'_>,
617        ) -> Poll<Result<(), tokio::io::Error>> {
618            let this = self.project();
619            Write::poll_flush(this.inner, cx)
620        }
621
622        fn poll_shutdown(
623            self: Pin<&mut Self>,
624            cx: &mut Context<'_>,
625        ) -> Poll<Result<(), tokio::io::Error>> {
626            let this = self.project();
627            Write::poll_shutdown(this.inner, cx)
628        }
629    }
630
631    #[cfg(test)]
632    mod tests {
633        use super::*;
634        use rustls::CertificateError;
635
636        #[test]
637        fn rustls_rejection_is_tagged_protocol() {
638            // hyper-rustls wraps tokio-rustls's `InvalidData` error in `io::Error::other`.
639            let rejection = io::Error::other(io::Error::new(
640                io::ErrorKind::InvalidData,
641                rustls::Error::InvalidCertificate(CertificateError::UnknownIssuer),
642            ));
643
644            let error = classify_rustls(rejection.into());
645
646            let tls = error
647                .downcast_ref::<TlsConnectError>()
648                .expect("a rustls rejection is tagged");
649            assert_eq!(TlsConnectErrorKind::Protocol, tls.kind());
650            assert!(
651                error_chain(tls).any(|error| error.is::<rustls::Error>()),
652                "the tag keeps the rustls error in its source chain"
653            );
654        }
655
656        #[test]
657        fn handshake_eof_passes_through_untagged() {
658            let eof = io::Error::new(io::ErrorKind::UnexpectedEof, "tls handshake eof");
659
660            let error = classify_rustls(eof.into());
661
662            assert!(!error.is::<TlsConnectError>());
663            let io = error
664                .downcast_ref::<io::Error>()
665                .expect("the I/O error is returned unchanged");
666            assert_eq!(io::ErrorKind::UnexpectedEof, io.kind());
667        }
668    }
669}