Skip to main content

mssql_client/client/
connect.rs

1//! Connection establishment for SQL Server.
2//!
3//! This module contains the `impl Client<Disconnected>` block, handling
4//! TCP connection, TLS negotiation, PreLogin exchange, and Login7 authentication.
5
6use std::marker::PhantomData;
7use std::net::SocketAddr;
8
9use bytes::BytesMut;
10use mssql_codec::connection::Connection;
11#[cfg(feature = "tls")]
12use mssql_tls::{TlsConfig, TlsConnector, TlsNegotiationMode};
13use tds_protocol::login7::Login7;
14use tds_protocol::packet::MAX_PACKET_SIZE;
15use tds_protocol::packet::PacketType;
16use tds_protocol::prelogin::{EncryptionLevel, PreLogin};
17use tds_protocol::token::{EnvChange, EnvChangeType, Token, TokenParser};
18use tokio::net::TcpStream;
19use tokio::time::timeout;
20
21use crate::config::Config;
22use crate::error::{Error, Result};
23#[cfg(feature = "otel")]
24use crate::instrumentation::InstrumentationContext;
25use crate::state::{Disconnected, Ready};
26use crate::statement_cache::StatementCache;
27
28use super::{Client, ConnectionHandle};
29
30impl Client<Disconnected> {
31    /// Connect to SQL Server.
32    ///
33    /// This establishes a connection, performs TLS negotiation (if required),
34    /// and authenticates with the server.
35    ///
36    /// # Example
37    ///
38    /// ```rust,no_run
39    /// # use mssql_client::Client;
40    /// # async fn ex(config: mssql_client::Config) -> Result<(), mssql_client::Error> {
41    /// let client = Client::connect(config).await?;
42    /// # let _ = client;
43    /// # Ok(())
44    /// # }
45    /// ```
46    pub async fn connect(config: Config) -> Result<Client<Ready>> {
47        // FEDAUTH-based credentials (Azure AD / Entra and client certificate)
48        // are not yet wired into the login sequence: providers can acquire
49        // tokens, but LOGIN7 carries no FEDAUTH feature extension, so the
50        // server would receive an empty-credential login and reject it with
51        // an opaque error 18456. Fail fast with an actionable error instead.
52        // Full FEDAUTH support is tracked in issue #155.
53        match &config.credentials {
54            mssql_auth::Credentials::SqlServer { .. } => {}
55            #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
56            mssql_auth::Credentials::Integrated => {}
57            // Every other credential type (Azure access token, managed
58            // identity, service principal, client certificate) is
59            // FEDAUTH-based and rejected here.
60            _ => {
61                return Err(Error::Config(
62                    "Azure AD / Entra and client certificate (FEDAUTH) authentication \
63                     are not yet supported: the LOGIN7 FEDAUTH feature extension is not \
64                     implemented (tracked in \
65                     https://github.com/praxiomlabs/rust-mssql-driver/issues/155). \
66                     Use SQL Server or integrated authentication."
67                        .into(),
68                ));
69            }
70        }
71
72        let retry = config.retry.clone();
73        let max_redirects = config.redirect.max_redirects;
74        let follow_redirects = config.redirect.follow_redirects;
75        // Overall timeout accounts for retries + redirects per attempt, capped at 5 min.
76        let per_attempt = config.timeouts.connect_timeout
77            + config.timeouts.tls_timeout
78            + config.timeouts.login_timeout;
79        let total_attempts = (retry.max_retries + 1) * (max_redirects as u32 + 1);
80        let overall = (per_attempt * total_attempts).min(std::time::Duration::from_secs(300));
81        let initial_host = config.host.clone();
82        let initial_port = config.port;
83
84        let result = timeout(overall, async {
85            let mut last_error: Option<Error> = None;
86
87            for retry_attempt in 0..=retry.max_retries {
88                if retry_attempt > 0 {
89                    let backoff = retry.backoff_for_attempt(retry_attempt);
90                    tracing::info!(
91                        retry_attempt,
92                        backoff_ms = backoff.as_millis() as u64,
93                        "retrying connection after transient error"
94                    );
95                    tokio::time::sleep(backoff).await;
96                }
97
98                // Each retry starts fresh with original host/port
99                let mut current_config = config.clone();
100                let mut redirect_count: u8 = 0;
101
102                let attempt_result = loop {
103                    redirect_count += 1;
104                    if redirect_count > max_redirects + 1 {
105                        break Err(Error::TooManyRedirects { max: max_redirects });
106                    }
107
108                    match Self::try_connect(&current_config).await {
109                        Ok(client) => break Ok(client),
110                        Err(Error::Routing { host, port }) => {
111                            if !follow_redirects {
112                                break Err(Error::Routing { host, port });
113                            }
114                            tracing::info!(
115                                host = %host,
116                                port = port,
117                                redirect = redirect_count,
118                                max_redirects = max_redirects,
119                                "following Azure SQL routing redirect"
120                            );
121                            current_config = current_config.with_host(&host).with_port(port);
122                            continue;
123                        }
124                        Err(e) => break Err(e),
125                    }
126                };
127
128                match attempt_result {
129                    Ok(client) => return Ok(client),
130                    Err(ref e) if e.is_transient() && retry.should_retry(retry_attempt) => {
131                        tracing::warn!(
132                            retry_attempt,
133                            max_retries = retry.max_retries,
134                            error = %e,
135                            "transient connection error, will retry"
136                        );
137                        last_error = Some(attempt_result.unwrap_err());
138                    }
139                    Err(e) => return Err(e),
140                }
141            }
142
143            // All retries exhausted — return last error
144            Err(last_error.expect("at least one attempt was made"))
145        })
146        .await;
147
148        match result {
149            Ok(inner) => inner,
150            Err(_elapsed) => Err(Error::ConnectTimeout {
151                host: initial_host,
152                port: initial_port,
153            }),
154        }
155    }
156
157    async fn try_connect(config: &Config) -> Result<Client<Ready>> {
158        // If a named instance is specified, resolve the TCP port via SQL Browser
159        let port = if let Some(ref instance) = config.instance {
160            let resolved = crate::browser::resolve_instance(
161                &config.host,
162                instance,
163                Some(config.timeouts.connect_timeout),
164            )
165            .await?;
166            tracing::info!(
167                host = %config.host,
168                instance = %instance,
169                resolved_port = resolved,
170                database = ?config.database,
171                "connecting to named SQL Server instance"
172            );
173            resolved
174        } else {
175            tracing::info!(
176                host = %config.host,
177                port = config.port,
178                database = ?config.database,
179                "connecting to SQL Server"
180            );
181            config.port
182        };
183
184        // Normalize "." and "(local)" to localhost for TCP.
185        // These are standard ADO.NET aliases for the local machine.
186        let host = if config.host == "." || config.host.eq_ignore_ascii_case("(local)") {
187            "127.0.0.1"
188        } else {
189            &config.host
190        };
191
192        // Step 1: Establish TCP connection
193        let tcp_stream = if config.multi_subnet_failover {
194            Self::connect_parallel(host, port, config.timeouts.connect_timeout).await?
195        } else {
196            let addr = format!("{host}:{port}");
197            tracing::debug!("establishing TCP connection to {}", addr);
198            let stream = timeout(config.timeouts.connect_timeout, TcpStream::connect(&addr))
199                .await
200                .map_err(|_| Error::ConnectTimeout {
201                    host: config.host.clone(),
202                    port: config.port,
203                })?
204                .map_err(Error::from)?;
205            stream.set_nodelay(true).map_err(Error::from)?;
206            stream
207        };
208
209        #[cfg(feature = "tls")]
210        {
211            // Determine TLS negotiation mode
212            let tls_mode = TlsNegotiationMode::from_encrypt_mode(config.strict_mode);
213
214            // Step 2: Handle TDS 8.0 strict mode (TLS before any TDS traffic)
215            if tls_mode.is_tls_first() {
216                return Self::connect_tds_8(config, tcp_stream).await;
217            }
218
219            // Step 3: TDS 7.x flow - PreLogin first, then TLS, then Login7
220            Self::connect_tds_7x(config, tcp_stream).await
221        }
222
223        #[cfg(not(feature = "tls"))]
224        {
225            // When TLS feature is disabled, only no_tls connections are supported
226            if config.strict_mode {
227                return Err(Error::Config(
228                    "TDS 8.0 strict mode requires TLS. Enable the 'tls' feature or use Encrypt=no_tls".into()
229                ));
230            }
231
232            if !config.no_tls {
233                return Err(Error::Config(
234                    "TLS encryption requires the 'tls' feature. Either enable the 'tls' feature \
235                     or use Encrypt=no_tls in your connection string for unencrypted connections."
236                        .into(),
237                ));
238            }
239
240            // Proceed with no-TLS connection
241            Self::connect_no_tls(config, tcp_stream).await
242        }
243    }
244
245    /// Resolve hostname to all IPs and race parallel TCP connections.
246    ///
247    /// Used when `MultiSubnetFailover=True` for AlwaysOn AG listeners that
248    /// span multiple subnets. First successful TCP connection wins.
249    async fn connect_parallel(
250        host: &str,
251        port: u16,
252        connect_timeout: std::time::Duration,
253    ) -> Result<TcpStream> {
254        let addr_str = format!("{host}:{port}");
255        let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
256            .await
257            .map_err(Error::from)?
258            .collect();
259
260        if addrs.is_empty() {
261            return Err(Error::from(std::io::Error::new(
262                std::io::ErrorKind::AddrNotAvailable,
263                format!("no addresses resolved for {host}:{port}"),
264            )));
265        }
266
267        // Single address — no need to spawn tasks
268        if addrs.len() == 1 {
269            tracing::debug!(addr = %addrs[0], "MultiSubnetFailover: single address resolved");
270            let stream = timeout(connect_timeout, TcpStream::connect(addrs[0]))
271                .await
272                .map_err(|_| Error::ConnectTimeout {
273                    host: host.to_string(),
274                    port,
275                })?
276                .map_err(Error::from)?;
277            stream.set_nodelay(true).map_err(Error::from)?;
278            return Ok(stream);
279        }
280
281        let addr_count = addrs.len();
282        tracing::debug!(
283            host = host,
284            port = port,
285            resolved_count = addr_count,
286            "MultiSubnetFailover: racing parallel connections",
287        );
288
289        let mut join_set = tokio::task::JoinSet::new();
290
291        for addr in addrs {
292            let dur = connect_timeout;
293            join_set.spawn(async move {
294                let tcp = timeout(dur, TcpStream::connect(addr)).await.map_err(|_| {
295                    std::io::Error::new(
296                        std::io::ErrorKind::TimedOut,
297                        format!("connection to {addr} timed out"),
298                    )
299                })??;
300                tcp.set_nodelay(true)?;
301                Ok::<(TcpStream, SocketAddr), std::io::Error>((tcp, addr))
302            });
303        }
304
305        let mut last_error: Option<std::io::Error> = None;
306
307        while let Some(result) = join_set.join_next().await {
308            match result {
309                Ok(Ok((stream, addr))) => {
310                    tracing::debug!(addr = %addr, "MultiSubnetFailover: connected");
311                    join_set.abort_all();
312                    return Ok(stream);
313                }
314                Ok(Err(e)) => {
315                    tracing::debug!(error = %e, "MultiSubnetFailover: attempt failed");
316                    last_error = Some(e);
317                }
318                Err(join_err) => {
319                    tracing::debug!(error = %join_err, "MultiSubnetFailover: task failed");
320                    last_error = Some(std::io::Error::other(join_err.to_string()));
321                }
322            }
323        }
324
325        // All connections failed
326        Err(Error::from(last_error.unwrap_or_else(|| {
327            std::io::Error::new(
328                std::io::ErrorKind::ConnectionRefused,
329                format!("all {addr_count} parallel connection attempts failed for {host}:{port}"),
330            )
331        })))
332    }
333
334    /// Connect using TDS 8.0 strict mode.
335    ///
336    /// Flow: TCP -> TLS -> PreLogin (encrypted) -> Login7 (encrypted)
337    #[cfg(feature = "tls")]
338    async fn connect_tds_8(config: &Config, tcp_stream: TcpStream) -> Result<Client<Ready>> {
339        tracing::debug!("using TDS 8.0 strict mode (TLS first)");
340
341        // Build TLS configuration from the user's `config.tls` plus the
342        // TDS 8.0 strict-mode requirements (see `connection_tls_config`).
343        let tls_config = connection_tls_config(config, true);
344
345        let tls_connector = TlsConnector::new(tls_config)?;
346
347        // Perform TLS handshake before any TDS traffic
348        let tls_stream = timeout(
349            config.timeouts.tls_timeout,
350            tls_connector.connect(tcp_stream, &config.host),
351        )
352        .await
353        .map_err(|_| Error::TlsTimeout {
354            host: config.host.clone(),
355            port: config.port,
356        })??;
357
358        tracing::debug!("TLS handshake completed (strict mode)");
359
360        // Create connection wrapper
361        let mut connection = Connection::new(tls_stream);
362        connection.set_max_message_size(config.max_response_size);
363
364        // Send PreLogin (encrypted in strict mode)
365        let prelogin = Self::build_prelogin(config, EncryptionLevel::Required);
366        Self::send_prelogin(&mut connection, &prelogin).await?;
367        let _prelogin_response = Self::receive_prelogin(&mut connection).await?;
368
369        // Create SSPI negotiator if integrated auth
370        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
371        let negotiator = Self::create_negotiator(config)?;
372        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
373        let sspi_token = match negotiator {
374            Some(ref neg) => Some(neg.initialize()?),
375            None => None,
376        };
377        #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
378        let sspi_token: Option<Vec<u8>> = None;
379
380        // Send Login7
381        let login = Self::build_login7(config, sspi_token);
382        Self::send_login7(&mut connection, &login).await?;
383
384        // Process login response (with timeout to prevent hangs during redirect)
385        let (server_version, current_database, routing, server_collation) = timeout(
386            config.timeouts.login_timeout,
387            Self::process_login_response(
388                &mut connection,
389                #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
390                negotiator.as_deref(),
391            ),
392        )
393        .await
394        .map_err(|_| Error::LoginTimeout {
395            host: config.host.clone(),
396            port: config.port,
397        })??;
398
399        // Handle routing redirect
400        if let Some((host, port)) = routing {
401            return Err(Error::Routing { host, port });
402        }
403
404        Ok(Client {
405            config: config.clone(),
406            _state: PhantomData,
407            connection: Some(ConnectionHandle::Tls(connection)),
408            server_version,
409            current_database: current_database.clone(),
410            server_collation,
411            statement_cache: StatementCache::with_default_size(),
412            transaction_descriptor: 0, // Auto-commit mode initially
413            needs_reset: false,        // Fresh connection, no reset needed
414            in_flight: false,          // No request pending
415            #[cfg(feature = "otel")]
416            instrumentation: InstrumentationContext::new(config.host.clone(), config.port)
417                .with_database(current_database.clone().unwrap_or_default()),
418            #[cfg(feature = "always-encrypted")]
419            encryption_context: config.column_encryption.clone().map(|cfg| {
420                std::sync::Arc::new(crate::encryption::EncryptionContext::from_arc(cfg))
421            }),
422        })
423    }
424
425    /// Connect using TDS 7.x flow.
426    ///
427    /// Flow: TCP -> PreLogin (clear) -> TLS -> Login7 (encrypted)
428    ///
429    /// Note: For TDS 7.x, the PreLogin exchange happens over raw TCP before
430    /// upgrading to TLS. We use low-level I/O for this initial exchange
431    /// since the Connection struct splits the stream immediately.
432    #[cfg(feature = "tls")]
433    async fn connect_tds_7x(config: &Config, mut tcp_stream: TcpStream) -> Result<Client<Ready>> {
434        use bytes::BufMut;
435        use tds_protocol::packet::{PACKET_HEADER_SIZE, PacketHeader, PacketStatus};
436        use tokio::io::{AsyncReadExt, AsyncWriteExt};
437
438        tracing::debug!("using TDS 7.x flow (PreLogin first)");
439
440        // Build PreLogin packet
441        // Determine client encryption level based on configuration
442        let client_encryption = if config.no_tls {
443            // no_tls: Completely disable TLS
444            tracing::warn!(
445                "⚠️  no_tls mode enabled. Connection will be UNENCRYPTED. \
446                 Credentials and data will be transmitted in plaintext. \
447                 This should only be used for development/testing with legacy SQL Server."
448            );
449            EncryptionLevel::NotSupported
450        } else if config.encrypt {
451            EncryptionLevel::On
452        } else {
453            EncryptionLevel::Off
454        };
455        let prelogin = Self::build_prelogin(config, client_encryption);
456        tracing::debug!(encryption = ?client_encryption, "sending PreLogin");
457        let prelogin_bytes = prelogin.encode();
458
459        // Manually create and send the PreLogin packet over raw TCP
460        let header = PacketHeader::new(
461            PacketType::PreLogin,
462            PacketStatus::END_OF_MESSAGE,
463            (PACKET_HEADER_SIZE + prelogin_bytes.len()) as u16,
464        );
465
466        let mut packet_buf = BytesMut::with_capacity(PACKET_HEADER_SIZE + prelogin_bytes.len());
467        header.encode(&mut packet_buf);
468        packet_buf.put_slice(&prelogin_bytes);
469
470        tcp_stream
471            .write_all(&packet_buf)
472            .await
473            .map_err(Error::from)?;
474
475        // Read PreLogin response
476        let mut header_buf = [0u8; PACKET_HEADER_SIZE];
477        tcp_stream
478            .read_exact(&mut header_buf)
479            .await
480            .map_err(Error::from)?;
481
482        let response_length = u16::from_be_bytes([header_buf[2], header_buf[3]]) as usize;
483        let payload_length = response_length.saturating_sub(PACKET_HEADER_SIZE);
484
485        let mut response_buf = vec![0u8; payload_length];
486        tcp_stream
487            .read_exact(&mut response_buf)
488            .await
489            .map_err(Error::from)?;
490
491        let prelogin_response = PreLogin::decode(&response_buf[..])?;
492
493        // Log PreLogin response
494        // Note: The server sends its SQL Server product version in PreLogin,
495        // NOT the TDS protocol version. The actual TDS version is negotiated
496        // in the LOGINACK token after login.
497        let client_tds_version = config.tds_version;
498        if let Some(ref server_version) = prelogin_response.server_version {
499            tracing::debug!(
500                requested_tds_version = %client_tds_version,
501                server_product_version = %server_version,
502                server_product = server_version.product_name(),
503                max_tds_version = %server_version.max_tds_version(),
504                "PreLogin response received"
505            );
506
507            // Warn if the server's max TDS version is lower than requested
508            let server_max_tds = server_version.max_tds_version();
509            if server_max_tds < client_tds_version && !client_tds_version.is_tds_8() {
510                tracing::warn!(
511                    requested_tds_version = %client_tds_version,
512                    server_max_tds_version = %server_max_tds,
513                    server_product = server_version.product_name(),
514                    "Server supports lower TDS version than requested. \
515                     Connection will use server's maximum: {}",
516                    server_max_tds
517                );
518            }
519
520            // Warn about legacy SQL Server versions (2005 and earlier)
521            if server_max_tds.is_legacy() {
522                tracing::warn!(
523                    server_product = server_version.product_name(),
524                    server_max_tds_version = %server_max_tds,
525                    "Server uses legacy TDS version. Some features may not be available."
526                );
527            }
528        } else {
529            tracing::debug!(
530                requested_tds_version = %client_tds_version,
531                "PreLogin response received (no version info)"
532            );
533        }
534
535        // Check server encryption response
536        let server_encryption = prelogin_response.encryption;
537        tracing::debug!(encryption = ?server_encryption, "server encryption level");
538
539        // Determine negotiated encryption level (follows TDS 7.x rules)
540        // - NotSupported + NotSupported = NotSupported (no TLS at all)
541        // - Off + Off = Off (TLS for login only, then plain)
542        // - On + anything supported = On (full TLS)
543        // - Required = On with failure if not possible
544        let negotiated_encryption = match (client_encryption, server_encryption) {
545            (EncryptionLevel::NotSupported, EncryptionLevel::NotSupported) => {
546                EncryptionLevel::NotSupported
547            }
548            (EncryptionLevel::Off, EncryptionLevel::Off) => EncryptionLevel::Off,
549            (EncryptionLevel::On, EncryptionLevel::Off)
550            | (EncryptionLevel::On, EncryptionLevel::NotSupported) => {
551                return Err(Error::Protocol(
552                    "Server does not support requested encryption level".to_string(),
553                ));
554            }
555            _ => EncryptionLevel::On,
556        };
557
558        // TLS is required unless negotiated encryption is NotSupported
559        // Even with "Off", TLS is used to protect login credentials (per TDS 7.x spec)
560        let use_tls = negotiated_encryption != EncryptionLevel::NotSupported;
561
562        if use_tls {
563            // Upgrade to TLS with PreLogin wrapping (TDS 7.x style).
564            // In TDS 7.x, the TLS handshake is wrapped inside TDS PreLogin
565            // packets. Honor the user's `config.tls` (custom root certs,
566            // client auth) without the TDS 8.0 strict ALPN.
567            let tls_config = connection_tls_config(config, false);
568
569            let tls_connector = TlsConnector::new(tls_config)?;
570
571            // Use PreLogin-wrapped TLS connection for TDS 7.x
572            let mut tls_stream = timeout(
573                config.timeouts.tls_timeout,
574                tls_connector.connect_with_prelogin(tcp_stream, &config.host),
575            )
576            .await
577            .map_err(|_| Error::TlsTimeout {
578                host: config.host.clone(),
579                port: config.port,
580            })??;
581
582            tracing::debug!("TLS handshake completed (PreLogin wrapped)");
583
584            // Check if we need full encryption or login-only encryption
585            let login_only_encryption = negotiated_encryption == EncryptionLevel::Off;
586
587            if login_only_encryption {
588                // Login-Only Encryption (ENCRYPT_OFF + ENCRYPT_OFF per MS-TDS spec):
589                // - Login7 is sent through TLS to protect credentials
590                // - Server responds in PLAINTEXT after receiving Login7
591                // - All subsequent communication is plaintext
592                //
593                // We must NOT use Connection with TLS stream because Connection splits
594                // the stream and we need to extract the underlying TCP afterward.
595                use tokio::io::AsyncWriteExt;
596
597                // Create SSPI negotiator if integrated auth
598                // Note: SSPI handshake over login-only encryption is limited —
599                // the server response comes in plaintext, so multi-step SSPI
600                // may not work. We include the initial token but don't loop.
601                #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
602                let negotiator = Self::create_negotiator(config)?;
603                #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
604                let sspi_token = match negotiator {
605                    Some(ref neg) => Some(neg.initialize()?),
606                    None => None,
607                };
608                #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
609                let sspi_token: Option<Vec<u8>> = None;
610
611                // Build and send Login7 directly through TLS
612                let login = Self::build_login7(config, sspi_token);
613                let login_payload = login.encode();
614
615                // Create TDS packet manually for Login7
616                let max_packet = MAX_PACKET_SIZE;
617                let max_payload = max_packet - PACKET_HEADER_SIZE;
618                let chunks: Vec<_> = login_payload.chunks(max_payload).collect();
619                let total_chunks = chunks.len();
620
621                for (i, chunk) in chunks.into_iter().enumerate() {
622                    let is_last = i == total_chunks - 1;
623                    let status = if is_last {
624                        PacketStatus::END_OF_MESSAGE
625                    } else {
626                        PacketStatus::NORMAL
627                    };
628
629                    let header = PacketHeader::new(
630                        PacketType::Tds7Login,
631                        status,
632                        (PACKET_HEADER_SIZE + chunk.len()) as u16,
633                    );
634
635                    let mut packet_buf = BytesMut::with_capacity(PACKET_HEADER_SIZE + chunk.len());
636                    header.encode(&mut packet_buf);
637                    packet_buf.put_slice(chunk);
638
639                    tls_stream
640                        .write_all(&packet_buf)
641                        .await
642                        .map_err(Error::from)?;
643                }
644
645                // Flush TLS to ensure all data is sent
646                tls_stream.flush().await.map_err(Error::from)?;
647
648                tracing::debug!("Login7 sent through TLS, switching to plaintext for response");
649
650                // Extract the underlying TCP stream from the TLS layer
651                // TlsStream::into_inner() returns (IO, ClientConnection)
652                // where IO is our TlsPreloginWrapper<TcpStream>
653                let (wrapper, _client_conn) = tls_stream.into_inner();
654                let tcp_stream = wrapper.into_inner();
655
656                // Create Connection from plain TCP for reading response
657                let mut connection = Connection::new(tcp_stream);
658                connection.set_max_message_size(config.max_response_size);
659
660                // Process login response (comes in plaintext, with timeout)
661                let (server_version, current_database, routing, server_collation) = timeout(
662                    config.timeouts.login_timeout,
663                    Self::process_login_response(
664                        &mut connection,
665                        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
666                        negotiator.as_deref(),
667                    ),
668                )
669                .await
670                .map_err(|_| Error::LoginTimeout {
671                    host: config.host.clone(),
672                    port: config.port,
673                })??;
674
675                // Handle routing redirect
676                if let Some((host, port)) = routing {
677                    return Err(Error::Routing { host, port });
678                }
679
680                // Store plain TCP connection for subsequent operations
681                Ok(Client {
682                    config: config.clone(),
683                    _state: PhantomData,
684                    connection: Some(ConnectionHandle::Plain(connection)),
685                    server_version,
686                    current_database: current_database.clone(),
687                    server_collation,
688                    statement_cache: StatementCache::with_default_size(),
689                    transaction_descriptor: 0, // Auto-commit mode initially
690                    needs_reset: false,        // Fresh connection, no reset needed
691                    in_flight: false,          // No request pending
692                    #[cfg(feature = "otel")]
693                    instrumentation: InstrumentationContext::new(config.host.clone(), config.port)
694                        .with_database(current_database.clone().unwrap_or_default()),
695                    #[cfg(feature = "always-encrypted")]
696                    encryption_context: config.column_encryption.clone().map(|cfg| {
697                        std::sync::Arc::new(crate::encryption::EncryptionContext::from_arc(cfg))
698                    }),
699                })
700            } else {
701                // Full Encryption (ENCRYPT_ON per MS-TDS spec):
702                // - All communication after TLS handshake goes through TLS
703                let mut connection = Connection::new(tls_stream);
704                connection.set_max_message_size(config.max_response_size);
705
706                // Create SSPI negotiator if integrated auth
707                #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
708                let negotiator = Self::create_negotiator(config)?;
709                #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
710                let sspi_token = match negotiator {
711                    Some(ref neg) => Some(neg.initialize()?),
712                    None => None,
713                };
714                #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
715                let sspi_token: Option<Vec<u8>> = None;
716
717                // Send Login7
718                let login = Self::build_login7(config, sspi_token);
719                Self::send_login7(&mut connection, &login).await?;
720
721                // Process login response (with timeout)
722                let (server_version, current_database, routing, server_collation) = timeout(
723                    config.timeouts.login_timeout,
724                    Self::process_login_response(
725                        &mut connection,
726                        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
727                        negotiator.as_deref(),
728                    ),
729                )
730                .await
731                .map_err(|_| Error::LoginTimeout {
732                    host: config.host.clone(),
733                    port: config.port,
734                })??;
735
736                // Handle routing redirect
737                if let Some((host, port)) = routing {
738                    return Err(Error::Routing { host, port });
739                }
740
741                Ok(Client {
742                    config: config.clone(),
743                    _state: PhantomData,
744                    connection: Some(ConnectionHandle::TlsPrelogin(connection)),
745                    server_version,
746                    current_database: current_database.clone(),
747                    server_collation,
748                    statement_cache: StatementCache::with_default_size(),
749                    transaction_descriptor: 0, // Auto-commit mode initially
750                    needs_reset: false,        // Fresh connection, no reset needed
751                    in_flight: false,          // No request pending
752                    #[cfg(feature = "otel")]
753                    instrumentation: InstrumentationContext::new(config.host.clone(), config.port)
754                        .with_database(current_database.clone().unwrap_or_default()),
755                    #[cfg(feature = "always-encrypted")]
756                    encryption_context: config.column_encryption.clone().map(|cfg| {
757                        std::sync::Arc::new(crate::encryption::EncryptionContext::from_arc(cfg))
758                    }),
759                })
760            }
761        } else {
762            // Server does not require encryption and client doesn't either
763            tracing::warn!(
764                "Connecting without TLS encryption. This is insecure and should only be \
765                 used for development/testing on trusted networks."
766            );
767
768            let mut connection = Connection::new(tcp_stream);
769
770            connection.set_max_message_size(config.max_response_size);
771
772            // Create SSPI negotiator if integrated auth
773            #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
774            let negotiator = Self::create_negotiator(config)?;
775            #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
776            let sspi_token = match negotiator {
777                Some(ref neg) => Some(neg.initialize()?),
778                None => None,
779            };
780            #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
781            let sspi_token: Option<Vec<u8>> = None;
782
783            // Build and send Login7
784            let login = Self::build_login7(config, sspi_token);
785            Self::send_login7(&mut connection, &login).await?;
786
787            // Process login response (with timeout)
788            let (server_version, current_database, routing, server_collation) = timeout(
789                config.timeouts.login_timeout,
790                Self::process_login_response(
791                    &mut connection,
792                    #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
793                    negotiator.as_deref(),
794                ),
795            )
796            .await
797            .map_err(|_| Error::LoginTimeout {
798                host: config.host.clone(),
799                port: config.port,
800            })??;
801
802            // Handle routing redirect
803            if let Some((host, port)) = routing {
804                return Err(Error::Routing { host, port });
805            }
806
807            Ok(Client {
808                config: config.clone(),
809                _state: PhantomData,
810                connection: Some(ConnectionHandle::Plain(connection)),
811                server_version,
812                current_database: current_database.clone(),
813                server_collation,
814                statement_cache: StatementCache::with_default_size(),
815                transaction_descriptor: 0, // Auto-commit mode initially
816                needs_reset: false,        // Fresh connection, no reset needed
817                in_flight: false,          // No request pending
818                #[cfg(feature = "otel")]
819                instrumentation: InstrumentationContext::new(config.host.clone(), config.port)
820                    .with_database(current_database.clone().unwrap_or_default()),
821                #[cfg(feature = "always-encrypted")]
822                encryption_context: config.column_encryption.clone().map(|cfg| {
823                    std::sync::Arc::new(crate::encryption::EncryptionContext::from_arc(cfg))
824                }),
825            })
826        }
827    }
828
829    /// Connect without TLS encryption (no_tls mode).
830    ///
831    /// This method is used when the `tls` feature is disabled and only supports
832    /// unencrypted connections via `Encrypt=no_tls`.
833    ///
834    /// # Security Warning
835    ///
836    /// This transmits all data including credentials in plaintext. Only use this
837    /// for development, testing, or on trusted internal networks where TLS is not
838    /// required.
839    #[cfg(not(feature = "tls"))]
840    async fn connect_no_tls(config: &Config, mut tcp_stream: TcpStream) -> Result<Client<Ready>> {
841        use bytes::BufMut;
842        use tds_protocol::packet::{PACKET_HEADER_SIZE, PacketHeader, PacketStatus};
843        use tokio::io::{AsyncReadExt, AsyncWriteExt};
844
845        tracing::warn!(
846            "⚠️  Connecting without TLS (tls feature disabled). \
847             Credentials and data will be transmitted in plaintext."
848        );
849
850        // Build PreLogin packet with NotSupported encryption
851        let prelogin = Self::build_prelogin(config, EncryptionLevel::NotSupported);
852        let prelogin_bytes = prelogin.encode();
853
854        // Manually create and send the PreLogin packet over raw TCP
855        let header = PacketHeader::new(
856            PacketType::PreLogin,
857            PacketStatus::END_OF_MESSAGE,
858            (PACKET_HEADER_SIZE + prelogin_bytes.len()) as u16,
859        );
860
861        let mut packet_buf = BytesMut::with_capacity(PACKET_HEADER_SIZE + prelogin_bytes.len());
862        header.encode(&mut packet_buf);
863        packet_buf.put_slice(&prelogin_bytes);
864
865        tcp_stream
866            .write_all(&packet_buf)
867            .await
868            .map_err(Error::from)?;
869
870        // Read PreLogin response
871        let mut header_buf = [0u8; PACKET_HEADER_SIZE];
872        tcp_stream
873            .read_exact(&mut header_buf)
874            .await
875            .map_err(Error::from)?;
876
877        let response_length = u16::from_be_bytes([header_buf[2], header_buf[3]]) as usize;
878        let payload_length = response_length.saturating_sub(PACKET_HEADER_SIZE);
879
880        let mut response_buf = vec![0u8; payload_length];
881        tcp_stream
882            .read_exact(&mut response_buf)
883            .await
884            .map_err(Error::from)?;
885
886        let prelogin_response = PreLogin::decode(&response_buf[..])?;
887
888        // Check server encryption response - must accept NotSupported
889        let server_encryption = prelogin_response.encryption;
890        if server_encryption != EncryptionLevel::NotSupported {
891            return Err(Error::Config(format!(
892                "Server requires encryption (level: {:?}) but TLS feature is disabled. \
893                     Either enable the 'tls' feature or configure the server to allow unencrypted connections.",
894                server_encryption
895            )));
896        }
897
898        tracing::debug!("Server accepted unencrypted connection");
899
900        let mut connection = Connection::new(tcp_stream);
901
902        connection.set_max_message_size(config.max_response_size);
903
904        // Create SSPI negotiator if integrated auth
905        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
906        let negotiator = Self::create_negotiator(config)?;
907        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
908        let sspi_token = match negotiator {
909            Some(ref neg) => Some(neg.initialize()?),
910            None => None,
911        };
912        #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
913        let sspi_token: Option<Vec<u8>> = None;
914
915        // Build and send Login7
916        let login = Self::build_login7(config, sspi_token);
917        Self::send_login7(&mut connection, &login).await?;
918
919        // Process login response (with timeout)
920        let (server_version, current_database, routing, server_collation) = timeout(
921            config.timeouts.login_timeout,
922            Self::process_login_response(
923                &mut connection,
924                #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
925                negotiator.as_deref(),
926            ),
927        )
928        .await
929        .map_err(|_| Error::LoginTimeout {
930            host: config.host.clone(),
931            port: config.port,
932        })??;
933
934        // Handle routing redirect
935        if let Some((host, port)) = routing {
936            return Err(Error::Routing { host, port });
937        }
938
939        Ok(Client {
940            config: config.clone(),
941            _state: PhantomData,
942            connection: Some(ConnectionHandle::Plain(connection)),
943            server_version,
944            current_database: current_database.clone(),
945            server_collation,
946            statement_cache: StatementCache::with_default_size(),
947            transaction_descriptor: 0,
948            needs_reset: false,
949            in_flight: false,
950            #[cfg(feature = "otel")]
951            instrumentation: InstrumentationContext::new(config.host.clone(), config.port)
952                .with_database(current_database.clone().unwrap_or_default()),
953            #[cfg(feature = "always-encrypted")]
954            encryption_context: config.column_encryption.clone().map(|cfg| {
955                std::sync::Arc::new(crate::encryption::EncryptionContext::from_arc(cfg))
956            }),
957        })
958    }
959
960    /// Build a PreLogin packet.
961    fn build_prelogin(config: &Config, encryption: EncryptionLevel) -> PreLogin {
962        // Use the configured TDS version (strict_mode overrides to V8_0)
963        let version = if config.strict_mode {
964            tds_protocol::version::TdsVersion::V8_0
965        } else {
966            config.tds_version
967        };
968
969        let mut prelogin = PreLogin::new()
970            .with_version(version)
971            .with_encryption(encryption);
972
973        if config.mars {
974            prelogin = prelogin.with_mars(true);
975        }
976
977        if let Some(ref instance) = config.instance {
978            prelogin = prelogin.with_instance(instance);
979        }
980
981        prelogin
982    }
983
984    /// Resolve the workstation ID for the LOGIN7 HostName field.
985    ///
986    /// Per MS-TDS, the LOGIN7 HostName field contains the client machine name
987    /// (not the server name). Priority:
988    /// 1. `Config::workstation_id` (explicit override)
989    /// 2. Machine hostname from environment (`COMPUTERNAME` on Windows, `HOSTNAME` on Linux)
990    /// 3. Empty string (fallback)
991    fn resolve_workstation_id(config: &Config) -> String {
992        if let Some(ref id) = config.workstation_id {
993            return id.clone();
994        }
995        // COMPUTERNAME is set on Windows; HOSTNAME is set on most Linux systems.
996        // This avoids adding a dependency for a simple lookup.
997        std::env::var("COMPUTERNAME")
998            .or_else(|_| std::env::var("HOSTNAME"))
999            .unwrap_or_default()
1000    }
1001
1002    /// Build a Login7 packet.
1003    ///
1004    /// When `sspi_token` is provided (integrated auth), the Login7 packet is
1005    /// built with the integrated security flag and the initial SSPI blob.
1006    fn build_login7(config: &Config, sspi_token: Option<Vec<u8>>) -> Login7 {
1007        // Use the configured TDS version (strict_mode overrides to V8_0)
1008        let version = if config.strict_mode {
1009            tds_protocol::version::TdsVersion::V8_0
1010        } else {
1011            config.tds_version
1012        };
1013
1014        let mut login = Login7::new()
1015            .with_tds_version(version)
1016            .with_packet_size(config.packet_size as u32)
1017            .with_app_name(&config.application_name)
1018            .with_server_name(&config.host)
1019            .with_hostname(Self::resolve_workstation_id(config));
1020
1021        if let Some(ref database) = config.database {
1022            login = login.with_database(database);
1023        }
1024
1025        // ApplicationIntent → LOGIN7 TypeFlags READONLY_INTENT bit
1026        if config.application_intent == crate::config::ApplicationIntent::ReadOnly {
1027            login = login.with_read_only_intent(true);
1028        }
1029
1030        // Session language → LOGIN7 Language field
1031        if let Some(ref lang) = config.language {
1032            login = login.with_language(lang);
1033        }
1034
1035        // Set credentials
1036        if let Some(token) = sspi_token {
1037            // Integrated auth: set SSPI data and integrated security flag
1038            login = login.with_integrated_auth(token);
1039        } else if let mssql_auth::Credentials::SqlServer { username, password } =
1040            &config.credentials
1041        {
1042            login = login.with_sql_auth(username.as_ref(), password.as_ref());
1043        }
1044
1045        // When Always Encrypted is configured, add the ColumnEncryption feature extension.
1046        // Version 1 = client supports column encryption without enclave computations.
1047        #[cfg(feature = "always-encrypted")]
1048        if config.column_encryption.is_some() {
1049            login = login.with_feature(tds_protocol::login7::FeatureExtension {
1050                feature_id: tds_protocol::login7::FeatureId::ColumnEncryption,
1051                data: bytes::Bytes::from_static(&[0x01]), // Version 1
1052            });
1053            tracing::debug!("Login7: adding ColumnEncryption feature extension (version 1)");
1054        }
1055
1056        login
1057    }
1058
1059    /// Create an SSPI/GSSAPI negotiator if integrated auth is configured.
1060    ///
1061    /// Returns `None` for non-integrated credential types.
1062    ///
1063    /// On Windows with `sspi-auth`, uses native Windows SSPI (`secur32.dll`) which
1064    /// supports all account types including Microsoft Accounts. Falls back to sspi-rs
1065    /// on non-Windows platforms.
1066    ///
1067    /// With `integrated-auth` (Linux/macOS), uses GSSAPI/Kerberos.
1068    #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
1069    fn create_negotiator(config: &Config) -> Result<Option<Box<dyn mssql_auth::SspiNegotiator>>> {
1070        #[allow(clippy::match_like_matches_macro)]
1071        let is_integrated = match &config.credentials {
1072            mssql_auth::Credentials::Integrated => true,
1073            _ => false,
1074        };
1075
1076        if !is_integrated {
1077            return Ok(None);
1078        }
1079
1080        // On Windows: prefer native SSPI (secur32.dll) for integrated auth.
1081        // This handles all Windows account types including Microsoft Accounts,
1082        // domain accounts, and local accounts — unlike sspi-rs which requires
1083        // explicit credentials.
1084        #[cfg(all(windows, feature = "sspi-auth"))]
1085        let negotiator: Box<dyn mssql_auth::SspiNegotiator> =
1086            Box::new(mssql_auth::NativeSspiAuth::new(&config.host, config.port)?);
1087
1088        // On non-Windows: use sspi-rs (pure Rust SSPI implementation)
1089        #[cfg(all(not(windows), feature = "sspi-auth"))]
1090        let negotiator: Box<dyn mssql_auth::SspiNegotiator> =
1091            Box::new(mssql_auth::SspiAuth::new(&config.host, config.port)?);
1092
1093        #[cfg(all(feature = "integrated-auth", not(feature = "sspi-auth")))]
1094        let negotiator: Box<dyn mssql_auth::SspiNegotiator> =
1095            Box::new(mssql_auth::IntegratedAuth::new(&config.host, config.port));
1096
1097        Ok(Some(negotiator))
1098    }
1099
1100    /// Send a PreLogin packet (for use with Connection).
1101    #[cfg(feature = "tls")]
1102    async fn send_prelogin<T>(connection: &mut Connection<T>, prelogin: &PreLogin) -> Result<()>
1103    where
1104        T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
1105    {
1106        let payload = prelogin.encode();
1107        let max_packet = MAX_PACKET_SIZE;
1108
1109        connection
1110            .send_message(PacketType::PreLogin, payload, max_packet)
1111            .await?;
1112        Ok(())
1113    }
1114
1115    /// Receive a PreLogin response (for use with Connection).
1116    #[cfg(feature = "tls")]
1117    async fn receive_prelogin<T>(connection: &mut Connection<T>) -> Result<PreLogin>
1118    where
1119        T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
1120    {
1121        let message = connection
1122            .read_message()
1123            .await?
1124            .ok_or(Error::ConnectionClosed)?;
1125
1126        Ok(PreLogin::decode(&message.payload[..])?)
1127    }
1128
1129    /// Send a Login7 packet.
1130    async fn send_login7<T>(connection: &mut Connection<T>, login: &Login7) -> Result<()>
1131    where
1132        T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
1133    {
1134        let payload = login.encode();
1135        let max_packet = MAX_PACKET_SIZE;
1136
1137        connection
1138            .send_message(PacketType::Tds7Login, payload, max_packet)
1139            .await?;
1140        Ok(())
1141    }
1142
1143    /// Process the login response tokens, handling SSPI challenge/response if needed.
1144    ///
1145    /// When a `negotiator` is provided and the server sends an SSPI challenge token,
1146    /// this method will automatically perform the multi-step SSPI handshake by:
1147    /// 1. Calling `negotiator.step(challenge)` to generate a response
1148    /// 2. Sending the response via an SSPI packet
1149    /// 3. Reading the next server message and continuing
1150    ///
1151    /// Returns: (server_version, database, routing_info)
1152    #[allow(clippy::never_loop)] // Loop is used when integrated-auth/sspi-auth features are enabled
1153    async fn process_login_response<T>(
1154        connection: &mut Connection<T>,
1155        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))] negotiator: Option<
1156            &dyn mssql_auth::SspiNegotiator,
1157        >,
1158    ) -> Result<(
1159        Option<u32>,
1160        Option<String>,
1161        Option<(String, u16)>,
1162        Option<tds_protocol::token::Collation>,
1163    )>
1164    where
1165        T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
1166    {
1167        let mut server_version = None;
1168        let mut database = None;
1169        let mut routing = None;
1170        let mut collation = None;
1171
1172        'outer: loop {
1173            let message = connection
1174                .read_message()
1175                .await?
1176                .ok_or(Error::ConnectionClosed)?;
1177
1178            let response_bytes = message.payload;
1179            let mut parser = TokenParser::new(response_bytes);
1180
1181            while let Some(token) = parser.next_token()? {
1182                match token {
1183                    Token::LoginAck(ack) => {
1184                        tracing::info!(
1185                            version = ack.tds_version,
1186                            interface = ack.interface,
1187                            prog_name = %ack.prog_name,
1188                            "login acknowledged"
1189                        );
1190                        server_version = Some(ack.tds_version);
1191                    }
1192                    Token::EnvChange(env) => {
1193                        Self::process_env_change(&env, &mut database, &mut routing, &mut collation);
1194                    }
1195                    #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
1196                    Token::Sspi(sspi_token) => {
1197                        let neg = negotiator.ok_or_else(|| {
1198                            Error::Protocol(
1199                                "server sent SSPI challenge but no negotiator is configured"
1200                                    .to_string(),
1201                            )
1202                        })?;
1203
1204                        tracing::debug!(
1205                            challenge_len = sspi_token.data.len(),
1206                            "received SSPI challenge from server"
1207                        );
1208
1209                        if let Some(response) = neg.step(&sspi_token.data)? {
1210                            tracing::debug!(response_len = response.len(), "sending SSPI response");
1211                            connection
1212                                .send_message(
1213                                    PacketType::Sspi,
1214                                    bytes::Bytes::from(response),
1215                                    tds_protocol::packet::MAX_PACKET_SIZE,
1216                                )
1217                                .await?;
1218                        }
1219
1220                        // After sending the SSPI response, read the next server message
1221                        continue 'outer;
1222                    }
1223                    Token::Error(err) => {
1224                        return Err(Error::Server {
1225                            number: err.number,
1226                            state: err.state,
1227                            class: err.class,
1228                            message: err.message.clone(),
1229                            server: if err.server.is_empty() {
1230                                None
1231                            } else {
1232                                Some(err.server.clone())
1233                            },
1234                            procedure: if err.procedure.is_empty() {
1235                                None
1236                            } else {
1237                                Some(err.procedure.clone())
1238                            },
1239                            line: err.line as u32,
1240                        });
1241                    }
1242                    Token::Info(info) => {
1243                        tracing::info!(
1244                            number = info.number,
1245                            message = %info.message,
1246                            "server info message"
1247                        );
1248                    }
1249                    Token::Done(done) => {
1250                        if done.status.error {
1251                            return Err(Error::Protocol("login failed".to_string()));
1252                        }
1253                        break 'outer;
1254                    }
1255                    _ => {}
1256                }
1257            }
1258
1259            // If we consumed all tokens without a Done or SSPI, break
1260            break;
1261        }
1262
1263        Ok((server_version, database, routing, collation))
1264    }
1265
1266    /// Process an EnvChange token.
1267    fn process_env_change(
1268        env: &EnvChange,
1269        database: &mut Option<String>,
1270        routing: &mut Option<(String, u16)>,
1271        collation: &mut Option<tds_protocol::token::Collation>,
1272    ) {
1273        use tds_protocol::token::EnvChangeValue;
1274
1275        match env.env_type {
1276            EnvChangeType::Database => {
1277                if let EnvChangeValue::String(ref new_value) = env.new_value {
1278                    tracing::debug!(database = %new_value, "database changed");
1279                    *database = Some(new_value.clone());
1280                }
1281            }
1282            EnvChangeType::Routing => {
1283                if let EnvChangeValue::Routing { ref host, port } = env.new_value {
1284                    tracing::info!(host = %host, port = port, "routing redirect received");
1285                    *routing = Some((host.clone(), port));
1286                }
1287            }
1288            EnvChangeType::SqlCollation => {
1289                if let EnvChangeValue::Binary(ref data) = env.new_value {
1290                    if data.len() >= 5 {
1291                        let c = tds_protocol::token::Collation::from_bytes(
1292                            data[..5].try_into().unwrap(),
1293                        );
1294                        tracing::debug!(
1295                            lcid = c.lcid,
1296                            sort_id = c.sort_id,
1297                            "server collation received"
1298                        );
1299                        *collation = Some(c);
1300                    }
1301                }
1302            }
1303            _ => {
1304                if let EnvChangeValue::String(ref new_value) = env.new_value {
1305                    tracing::debug!(
1306                        env_type = ?env.env_type,
1307                        new_value = %new_value,
1308                        "environment change"
1309                    );
1310                }
1311            }
1312        }
1313    }
1314}
1315
1316/// Build the TLS configuration for an outbound connection.
1317///
1318/// Starts from the user's [`Config::tls`] so custom root certificates, client
1319/// auth, and protocol-version bounds are honored, then layers the
1320/// connection-specific requirements. `trust_server_certificate` is taken from
1321/// the authoritative top-level [`Config`] field: both the builder and the
1322/// connection-string parser set it, but the parser does not mirror it into
1323/// `config.tls`, so reading it here is what keeps `TrustServerCertificate=...`
1324/// connection strings working.
1325///
1326/// `strict` selects TDS 8.0 strict mode (TLS-first) and adds the `tds/8.0`
1327/// ALPN protocol; TDS 7.x leaves both off (its TLS is wrapped in PreLogin).
1328///
1329/// Note the asymmetry: root certificates and client auth come from
1330/// `config.tls`, but `trust_server_certificate` is taken from the top-level
1331/// field and overrides whatever `config.tls` holds. So setting *only*
1332/// `config.tls = TlsConfig::new().trust_server_certificate(true)` while
1333/// leaving the top-level field at its `false` default does not trust the
1334/// server — set it via the connection string (`TrustServerCertificate=true`)
1335/// or `Config::trust_server_certificate(true)`, which is the supported path.
1336#[cfg(feature = "tls")]
1337fn connection_tls_config(config: &Config, strict: bool) -> TlsConfig {
1338    let tls = config
1339        .tls
1340        .clone()
1341        .trust_server_certificate(config.trust_server_certificate);
1342    if strict {
1343        tls.strict_mode(true)
1344            .with_alpn_protocols(vec![b"tds/8.0".to_vec()])
1345    } else {
1346        tls
1347    }
1348}
1349
1350#[cfg(all(test, feature = "tls"))]
1351mod tls_config_tests {
1352    use super::*;
1353    use mssql_tls::CertificateDer;
1354
1355    fn config_with_root(cert: Vec<u8>) -> Config {
1356        let mut config = Config::new();
1357        config.tls = config
1358            .tls
1359            .clone()
1360            .add_root_certificate(CertificateDer::from(cert));
1361        config
1362    }
1363
1364    #[test]
1365    fn custom_root_certificate_reaches_connector_config() {
1366        // The bug: connect built a fresh TlsConfig and dropped config.tls,
1367        // so a custom CA was unreachable. Assert it survives into the
1368        // connection's TLS config, in both strict and non-strict paths.
1369        let config = config_with_root(vec![0xCA; 32]);
1370
1371        for strict in [true, false] {
1372            let tls = connection_tls_config(&config, strict);
1373            assert_eq!(
1374                tls.root_certificates.len(),
1375                1,
1376                "custom root must reach the connector (strict={strict})"
1377            );
1378            assert_eq!(tls.root_certificates[0].as_ref(), &[0xCA; 32][..]);
1379        }
1380    }
1381
1382    #[test]
1383    fn trust_server_certificate_taken_from_top_level_field() {
1384        // Mirrors the connection-string path, which sets the top-level field
1385        // without updating config.tls.
1386        let mut config = Config::new();
1387        config.trust_server_certificate = true;
1388        // config.tls still has the default (false) trust flag.
1389        assert!(!config.tls.trust_server_certificate);
1390
1391        let tls = connection_tls_config(&config, false);
1392        assert!(
1393            tls.trust_server_certificate,
1394            "top-level trust flag must win"
1395        );
1396    }
1397
1398    #[test]
1399    fn strict_mode_adds_tds8_alpn() {
1400        let config = Config::new();
1401        let strict = connection_tls_config(&config, true);
1402        assert!(strict.strict_mode);
1403        assert!(strict.alpn_protocols.iter().any(|p| p == b"tds/8.0"));
1404
1405        let non_strict = connection_tls_config(&config, false);
1406        assert!(!non_strict.strict_mode);
1407    }
1408}