Skip to main content

tds_protocol/
login7.rs

1//! TDS LOGIN7 packet construction.
2//!
3//! The LOGIN7 packet is sent by the client to authenticate with SQL Server.
4//! It contains client information, credentials, and feature negotiation data.
5//!
6//! ## Packet Structure
7//!
8//! The LOGIN7 packet has a complex structure with:
9//! - Fixed-length header (94 bytes)
10//! - Variable-length data section (strings are UTF-16LE)
11//! - Optional feature extension block
12//!
13//! ## Security Note
14//!
15//! The password is obfuscated (not encrypted) using a simple XOR + bit rotation.
16//! Always use TLS encryption for the connection.
17
18use bytes::{BufMut, Bytes, BytesMut};
19
20use crate::codec::write_utf16_string;
21use crate::prelude::*;
22use crate::version::TdsVersion;
23
24/// LOGIN7 packet header size (fixed portion).
25pub const LOGIN7_HEADER_SIZE: usize = 94;
26
27/// LOGIN7 option flags 1.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct OptionFlags1 {
30    /// Use big-endian byte order.
31    pub byte_order_be: bool,
32    /// Character set (0 = ASCII, 1 = EBCDIC).
33    pub char_ebcdic: bool,
34    /// Floating point representation (0 = IEEE 754, 1 = VAX, 2 = ND5000).
35    pub float_ieee: bool,
36    /// Dump/load off.
37    pub dump_load_off: bool,
38    /// Use DB notification.
39    pub use_db_notify: bool,
40    /// Database is fatal.
41    pub database_fatal: bool,
42    /// Set language warning.
43    pub set_lang_warn: bool,
44}
45
46impl OptionFlags1 {
47    /// Convert to byte.
48    ///
49    /// Per MS-TDS 2.2.6.3 LOGIN7, OptionFlags1 layout:
50    /// - bit 0: fByteOrder (0=little-endian, 1=big-endian)
51    /// - bit 1: fChar (0=ASCII, 1=EBCDIC)
52    /// - bits 2-3: fFloat (0=IEEE 754, 1=VAX, 2=ND5000)
53    /// - bit 4: fDumpLoad
54    /// - bit 5: fUseDB
55    /// - bit 6: fDatabase
56    /// - bit 7: fSetLang
57    #[must_use]
58    pub fn to_byte(&self) -> u8 {
59        let mut flags = 0u8;
60        if self.byte_order_be {
61            flags |= 0x01; // bit 0
62        }
63        if self.char_ebcdic {
64            flags |= 0x02; // bit 1
65        }
66        // Note: fFloat is bits 2-3, IEEE 754 = 0, so leave as 0 for IEEE
67        // float_ieee being true means we use IEEE (which is 0, the default)
68        if self.dump_load_off {
69            flags |= 0x10; // bit 4
70        }
71        if self.use_db_notify {
72            flags |= 0x20; // bit 5
73        }
74        if self.database_fatal {
75            flags |= 0x40; // bit 6
76        }
77        if self.set_lang_warn {
78            flags |= 0x80; // bit 7
79        }
80        flags
81    }
82}
83
84/// LOGIN7 option flags 2.
85#[derive(Debug, Clone, Copy, Default)]
86pub struct OptionFlags2 {
87    /// Language is fatal.
88    pub language_fatal: bool,
89    /// ODBC driver.
90    pub odbc: bool,
91    /// Obsolete: transaction boundary.
92    pub tran_boundary: bool,
93    /// Obsolete: cache connect.
94    pub cache_connect: bool,
95    /// User type (0 = Normal, 1 = Server, 2 = DQ login, 3 = Replication).
96    pub user_type: u8,
97    /// Integrated security.
98    pub integrated_security: bool,
99}
100
101impl OptionFlags2 {
102    /// Convert to byte.
103    #[must_use]
104    pub fn to_byte(&self) -> u8 {
105        let mut flags = 0u8;
106        if self.language_fatal {
107            flags |= 0x01;
108        }
109        if self.odbc {
110            flags |= 0x02;
111        }
112        if self.tran_boundary {
113            flags |= 0x04;
114        }
115        if self.cache_connect {
116            flags |= 0x08;
117        }
118        flags |= (self.user_type & 0x07) << 4;
119        if self.integrated_security {
120            flags |= 0x80;
121        }
122        flags
123    }
124}
125
126/// LOGIN7 type flags.
127#[derive(Debug, Clone, Copy, Default)]
128pub struct TypeFlags {
129    /// SQL type (0 = DFLT, 1 = TSQL).
130    pub sql_type: u8,
131    /// OLEDB driver.
132    pub oledb: bool,
133    /// Read-only intent.
134    pub read_only_intent: bool,
135}
136
137impl TypeFlags {
138    /// Convert to byte.
139    #[must_use]
140    pub fn to_byte(&self) -> u8 {
141        let mut flags = 0u8;
142        flags |= self.sql_type & 0x0F;
143        if self.oledb {
144            flags |= 0x10;
145        }
146        if self.read_only_intent {
147            flags |= 0x20;
148        }
149        flags
150    }
151}
152
153/// LOGIN7 option flags 3.
154#[derive(Debug, Clone, Copy, Default)]
155pub struct OptionFlags3 {
156    /// Change password.
157    pub change_password: bool,
158    /// User instance.
159    pub user_instance: bool,
160    /// Send YUKON binary XML.
161    pub send_yukon_binary_xml: bool,
162    /// Unknown collation handling.
163    pub unknown_collation_handling: bool,
164    /// Feature extension.
165    pub extension: bool,
166}
167
168impl OptionFlags3 {
169    /// Convert to byte.
170    #[must_use]
171    pub fn to_byte(&self) -> u8 {
172        let mut flags = 0u8;
173        if self.change_password {
174            flags |= 0x01;
175        }
176        if self.user_instance {
177            flags |= 0x02;
178        }
179        if self.send_yukon_binary_xml {
180            flags |= 0x04;
181        }
182        if self.unknown_collation_handling {
183            flags |= 0x08;
184        }
185        if self.extension {
186            flags |= 0x10;
187        }
188        flags
189    }
190}
191
192/// Feature extension types.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194#[repr(u8)]
195#[non_exhaustive]
196pub enum FeatureId {
197    /// Session recovery.
198    SessionRecovery = 0x01,
199    /// Federated authentication.
200    FedAuth = 0x02,
201    /// Column encryption.
202    ColumnEncryption = 0x04,
203    /// Global transactions.
204    GlobalTransactions = 0x05,
205    /// Azure SQL Support for DB.
206    AzureSqlSupport = 0x08,
207    /// Data classification.
208    DataClassification = 0x09,
209    /// UTF-8 support.
210    Utf8Support = 0x0A,
211    /// Azure SQL DNS Caching.
212    AzureSqlDnsCaching = 0x0B,
213    /// Terminator.
214    Terminator = 0xFF,
215}
216
217/// LOGIN7 packet builder.
218#[derive(Debug, Clone)]
219pub struct Login7 {
220    /// TDS version to request.
221    pub tds_version: TdsVersion,
222    /// Requested packet size.
223    pub packet_size: u32,
224    /// Client program version.
225    pub client_prog_version: u32,
226    /// Client process ID.
227    pub client_pid: u32,
228    /// Connection ID (for connection pooling).
229    pub connection_id: u32,
230    /// Option flags 1.
231    pub option_flags1: OptionFlags1,
232    /// Option flags 2.
233    pub option_flags2: OptionFlags2,
234    /// Type flags.
235    pub type_flags: TypeFlags,
236    /// Option flags 3.
237    pub option_flags3: OptionFlags3,
238    /// Client timezone offset in minutes.
239    pub client_timezone: i32,
240    /// Client LCID (locale ID).
241    pub client_lcid: u32,
242    /// Hostname (client machine name).
243    pub hostname: String,
244    /// Username for SQL authentication.
245    pub username: String,
246    /// Password for SQL authentication.
247    pub password: String,
248    /// Application name.
249    pub app_name: String,
250    /// Server name.
251    pub server_name: String,
252    /// Unused field.
253    pub unused: String,
254    /// Client library name.
255    pub library_name: String,
256    /// Language.
257    pub language: String,
258    /// Database name.
259    pub database: String,
260    /// Client ID (MAC address, typically zeros).
261    pub client_id: [u8; 6],
262    /// SSPI data for integrated authentication.
263    pub sspi_data: Vec<u8>,
264    /// Attach DB filename (for LocalDB).
265    pub attach_db_file: String,
266    /// New password (for password change).
267    pub new_password: String,
268    /// Feature extensions.
269    pub features: Vec<FeatureExtension>,
270}
271
272/// Feature extension data.
273#[derive(Debug, Clone)]
274pub struct FeatureExtension {
275    /// Feature ID.
276    pub feature_id: FeatureId,
277    /// Feature data.
278    pub data: Bytes,
279}
280
281impl Default for Login7 {
282    fn default() -> Self {
283        #[cfg(feature = "std")]
284        let client_pid = std::process::id();
285        #[cfg(not(feature = "std"))]
286        let client_pid = 0;
287
288        Self {
289            tds_version: TdsVersion::V7_4,
290            packet_size: 4096,
291            client_prog_version: 0,
292            client_pid,
293            connection_id: 0,
294            // Match Tiberius/standard SQL Server client flags
295            option_flags1: OptionFlags1 {
296                use_db_notify: true,
297                database_fatal: true,
298                ..Default::default()
299            },
300            option_flags2: OptionFlags2 {
301                language_fatal: true,
302                odbc: true,
303                ..Default::default()
304            },
305            type_flags: TypeFlags::default(), // TSQL type is in sql_type field
306            option_flags3: OptionFlags3 {
307                unknown_collation_handling: true,
308                ..Default::default()
309            },
310            client_timezone: 0,
311            client_lcid: 0x0409, // English (US)
312            hostname: String::new(),
313            username: String::new(),
314            password: String::new(),
315            app_name: String::from("rust-mssql-driver"),
316            server_name: String::new(),
317            unused: String::new(),
318            library_name: String::from("rust-mssql-driver"),
319            language: String::new(),
320            database: String::new(),
321            client_id: [0u8; 6],
322            sspi_data: Vec::new(),
323            attach_db_file: String::new(),
324            new_password: String::new(),
325            features: Vec::new(),
326        }
327    }
328}
329
330impl Login7 {
331    /// Create a new Login7 packet builder.
332    #[must_use]
333    pub fn new() -> Self {
334        Self::default()
335    }
336
337    /// Set the TDS version.
338    #[must_use]
339    pub fn with_tds_version(mut self, version: TdsVersion) -> Self {
340        self.tds_version = version;
341        self
342    }
343
344    /// Set SQL authentication credentials.
345    #[must_use]
346    pub fn with_sql_auth(
347        mut self,
348        username: impl Into<String>,
349        password: impl Into<String>,
350    ) -> Self {
351        self.username = username.into();
352        self.password = password.into();
353        self.option_flags2.integrated_security = false;
354        self
355    }
356
357    /// Enable integrated (Windows) authentication.
358    #[must_use]
359    pub fn with_integrated_auth(mut self, sspi_data: Vec<u8>) -> Self {
360        self.sspi_data = sspi_data;
361        self.option_flags2.integrated_security = true;
362        self
363    }
364
365    /// Set the database to connect to.
366    #[must_use]
367    pub fn with_database(mut self, database: impl Into<String>) -> Self {
368        self.database = database.into();
369        self
370    }
371
372    /// Set the hostname (client machine name).
373    #[must_use]
374    pub fn with_hostname(mut self, hostname: impl Into<String>) -> Self {
375        self.hostname = hostname.into();
376        self
377    }
378
379    /// Set the application name.
380    #[must_use]
381    pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
382        self.app_name = app_name.into();
383        self
384    }
385
386    /// Set the server name.
387    #[must_use]
388    pub fn with_server_name(mut self, server_name: impl Into<String>) -> Self {
389        self.server_name = server_name.into();
390        self
391    }
392
393    /// Set the session language for server warning/error messages.
394    ///
395    /// The language name can be up to 128 characters (e.g., `"us_english"`).
396    /// When not set, the server uses its default language.
397    #[must_use]
398    pub fn with_language(mut self, language: impl Into<String>) -> Self {
399        self.language = language.into();
400        self
401    }
402
403    /// Set the packet size.
404    #[must_use]
405    pub fn with_packet_size(mut self, packet_size: u32) -> Self {
406        self.packet_size = packet_size;
407        self
408    }
409
410    /// Enable read-only intent for readable secondary connections.
411    #[must_use]
412    pub fn with_read_only_intent(mut self, read_only: bool) -> Self {
413        self.type_flags.read_only_intent = read_only;
414        self
415    }
416
417    /// Add a feature extension.
418    #[must_use]
419    pub fn with_feature(mut self, feature: FeatureExtension) -> Self {
420        self.option_flags3.extension = true;
421        self.features.push(feature);
422        self
423    }
424
425    /// Encode the LOGIN7 packet to bytes.
426    #[must_use]
427    pub fn encode(&self) -> Bytes {
428        let mut buf = BytesMut::with_capacity(512);
429
430        // Calculate variable data offsets.
431        //
432        // Variable data starts after the 94-byte fixed header. Offsets are
433        // accumulated in u32 (not u16) so a >64KB SSPI blob and the fields
434        // that follow it do not wrap; each value is narrowed to the USHORT
435        // wire field at serialization. Per MS-TDS the offset of a zero-length
436        // field is ignored, so narrowing a post-SSPI offset is only observable
437        // for a non-empty field (guarded by debug_assert below).
438        let mut offset: u32 = LOGIN7_HEADER_SIZE as u32;
439
440        // Pre-calculate all UTF-16 lengths
441        let hostname_len = self.hostname.encode_utf16().count() as u16;
442        let username_len = self.username.encode_utf16().count() as u16;
443        let password_len = self.password.encode_utf16().count() as u16;
444        let app_name_len = self.app_name.encode_utf16().count() as u16;
445        let server_name_len = self.server_name.encode_utf16().count() as u16;
446        let unused_len = self.unused.encode_utf16().count() as u16;
447        let library_name_len = self.library_name.encode_utf16().count() as u16;
448        let language_len = self.language.encode_utf16().count() as u16;
449        let database_len = self.database.encode_utf16().count() as u16;
450        let sspi_len = self.sspi_data.len();
451        let attach_db_len = self.attach_db_file.encode_utf16().count() as u16;
452        let new_password_len = self.new_password.encode_utf16().count() as u16;
453
454        // SSPI length indirection (MS-TDS §2.2.6.4). When the blob does not fit
455        // the USHORT `cbSSPI`, `cbSSPI` is the 0xFFFF sentinel and the real
456        // length goes in the DWORD `cbSSPILong`. The server reads `cbSSPILong`
457        // only when `cbSSPI == 0xFFFF` (and a `cbSSPILong` of 0 then means "use
458        // 0xFFFF"). Emitting the long form for `len >= 0xFFFF` keeps the
459        // invariant `cbSSPI == 0xFFFF  <=>  cbSSPILong holds the real length`.
460        let (cb_sspi, cb_sspi_long): (u16, u32) = if sspi_len >= 0xFFFF {
461            (0xFFFF, sspi_len as u32)
462        } else {
463            (sspi_len as u16, 0)
464        };
465
466        // Build variable data buffer
467        let mut var_data = BytesMut::new();
468
469        // Hostname
470        let hostname_offset = offset;
471        write_utf16_string(&mut var_data, &self.hostname);
472        offset += u32::from(hostname_len) * 2;
473
474        // Username
475        let username_offset = offset;
476        write_utf16_string(&mut var_data, &self.username);
477        offset += u32::from(username_len) * 2;
478
479        // Password (obfuscated)
480        let password_offset = offset;
481        Self::write_obfuscated_password(&mut var_data, &self.password);
482        offset += u32::from(password_len) * 2;
483
484        // App name
485        let app_name_offset = offset;
486        write_utf16_string(&mut var_data, &self.app_name);
487        offset += u32::from(app_name_len) * 2;
488
489        // Server name
490        let server_name_offset = offset;
491        write_utf16_string(&mut var_data, &self.server_name);
492        offset += u32::from(server_name_len) * 2;
493
494        // Unused / Feature extension pointer.
495        //
496        // Per MS-TDS §2.2.6.4, when fExtension is set, the variable-length
497        // slot that normally holds the `Unused` string is replaced by a
498        // 4-byte u32 containing the absolute offset of the FeatureExt data
499        // block. The offset/length table's `ibExtension` field points to
500        // THAT u32 (not to the FeatureExt data itself), and `cbExtension`
501        // is fixed at 4.
502        //
503        // The previously-shipped code set `ibExtension = base` (feature
504        // data offset) instead of the offset of the 4-byte pointer, causing
505        // SQL Server to read the first four bytes of our FeatureExt blob
506        // (e.g., `0x04, 0x01, 0x00, 0x00` for ColumnEncryption version 1)
507        // as a u32 offset — landing deep inside the hostname string and
508        // failing the LOGIN7 parse. The connection was dropped mid-handshake
509        // with no server-side diagnostic, which is why the bug sat unnoticed.
510        let extension_offset = if self.option_flags3.extension {
511            // Absolute offset where the FeatureExt block will land once all
512            // remaining var_data fields are written.
513            let feature_data_offset = offset
514                + 4 // the u32 pointer we're about to write
515                + u32::from(library_name_len) * 2
516                + u32::from(language_len) * 2
517                + u32::from(database_len) * 2
518                + sspi_len as u32
519                + u32::from(attach_db_len) * 2
520                + u32::from(new_password_len) * 2;
521            let pointer_offset = offset;
522            // Write the u32 that ibExtension will point TO. Its value is the
523            // offset of the actual FeatureExt block.
524            var_data.put_u32_le(feature_data_offset);
525            offset += 4;
526            pointer_offset
527        } else {
528            let unused_offset = offset;
529            write_utf16_string(&mut var_data, &self.unused);
530            offset += u32::from(unused_len) * 2;
531            unused_offset
532        };
533
534        // Library name
535        let library_name_offset = offset;
536        write_utf16_string(&mut var_data, &self.library_name);
537        offset += u32::from(library_name_len) * 2;
538
539        // Language
540        let language_offset = offset;
541        write_utf16_string(&mut var_data, &self.language);
542        offset += u32::from(language_len) * 2;
543
544        // Database
545        let database_offset = offset;
546        write_utf16_string(&mut var_data, &self.database);
547        offset += u32::from(database_len) * 2;
548
549        // Client ID (6 bytes)
550        // (Already handled in fixed header)
551
552        // SSPI
553        let sspi_offset = offset;
554        var_data.put_slice(&self.sspi_data);
555        offset += sspi_len as u32;
556
557        // Attach DB file
558        let attach_db_offset = offset;
559        write_utf16_string(&mut var_data, &self.attach_db_file);
560        offset += u32::from(attach_db_len) * 2;
561
562        // Change password
563        let new_password_offset = offset;
564        if !self.new_password.is_empty() {
565            Self::write_obfuscated_password(&mut var_data, &self.new_password);
566        }
567        #[allow(unused_assignments)]
568        {
569            offset += u32::from(new_password_len) * 2;
570        }
571
572        // attach_db / change_password follow the SSPI blob, so a >64KB SSPI can
573        // push their offsets past USHORT. Those fields are not wired by the
574        // client (always empty), and the server ignores a zero-length field's
575        // offset, so the USHORT narrowing below is only observable for a
576        // non-empty field — an unsupported combination we refuse to emit
577        // silently.
578        debug_assert!(
579            attach_db_len == 0 || attach_db_offset <= u32::from(u16::MAX),
580            "attach_db offset {attach_db_offset} exceeds USHORT with a non-empty field",
581        );
582        debug_assert!(
583            new_password_len == 0 || new_password_offset <= u32::from(u16::MAX),
584            "change_password offset {new_password_offset} exceeds USHORT with a non-empty field",
585        );
586
587        // Feature extensions (if any)
588        if self.option_flags3.extension {
589            for feature in &self.features {
590                var_data.put_u8(feature.feature_id as u8);
591                var_data.put_u32_le(feature.data.len() as u32);
592                var_data.put_slice(&feature.data);
593            }
594            var_data.put_u8(FeatureId::Terminator as u8);
595        }
596
597        // Calculate total length
598        let total_length = LOGIN7_HEADER_SIZE + var_data.len();
599
600        // Write fixed header
601        buf.put_u32_le(total_length as u32); // Length
602        buf.put_u32_le(self.tds_version.raw()); // TDS version
603        buf.put_u32_le(self.packet_size); // Packet size
604        buf.put_u32_le(self.client_prog_version); // Client program version
605        buf.put_u32_le(self.client_pid); // Client PID
606        buf.put_u32_le(self.connection_id); // Connection ID
607
608        // Option flags
609        buf.put_u8(self.option_flags1.to_byte());
610        buf.put_u8(self.option_flags2.to_byte());
611        buf.put_u8(self.type_flags.to_byte());
612        buf.put_u8(self.option_flags3.to_byte());
613
614        buf.put_i32_le(self.client_timezone); // Client timezone
615        buf.put_u32_le(self.client_lcid); // Client LCID
616
617        // Variable length field offsets and lengths
618        buf.put_u16_le(hostname_offset as u16);
619        buf.put_u16_le(hostname_len);
620        buf.put_u16_le(username_offset as u16);
621        buf.put_u16_le(username_len);
622        buf.put_u16_le(password_offset as u16);
623        buf.put_u16_le(password_len);
624        buf.put_u16_le(app_name_offset as u16);
625        buf.put_u16_le(app_name_len);
626        buf.put_u16_le(server_name_offset as u16);
627        buf.put_u16_le(server_name_len);
628
629        // Extension offset (or unused)
630        if self.option_flags3.extension {
631            buf.put_u16_le(extension_offset as u16);
632            buf.put_u16_le(4); // Size of offset pointer
633        } else {
634            buf.put_u16_le(extension_offset as u16);
635            buf.put_u16_le(unused_len);
636        }
637
638        buf.put_u16_le(library_name_offset as u16);
639        buf.put_u16_le(library_name_len);
640        buf.put_u16_le(language_offset as u16);
641        buf.put_u16_le(language_len);
642        buf.put_u16_le(database_offset as u16);
643        buf.put_u16_le(database_len);
644
645        // Client ID (6 bytes)
646        buf.put_slice(&self.client_id);
647
648        buf.put_u16_le(sspi_offset as u16);
649        buf.put_u16_le(cb_sspi);
650        buf.put_u16_le(attach_db_offset as u16);
651        buf.put_u16_le(attach_db_len);
652        buf.put_u16_le(new_password_offset as u16);
653        buf.put_u16_le(new_password_len);
654
655        // SSPI Long (DWORD): the real SSPI length when cbSSPI is the 0xFFFF
656        // sentinel, otherwise 0. See the cb_sspi/cb_sspi_long computation above.
657        buf.put_u32_le(cb_sspi_long);
658
659        // Append variable data
660        buf.put_slice(&var_data);
661
662        buf.freeze()
663    }
664
665    /// Write password with TDS obfuscation.
666    ///
667    /// Per MS-TDS spec: For every byte in the password buffer, the client SHOULD first
668    /// swap the four high bits with the four low bits and then do a bit-XOR with 0xA5.
669    fn write_obfuscated_password(dst: &mut impl BufMut, password: &str) {
670        for c in password.encode_utf16() {
671            let low = (c & 0xFF) as u8;
672            let high = ((c >> 8) & 0xFF) as u8;
673
674            // Step 1: Swap nibbles (rotate by 4 bits)
675            // Step 2: XOR with 0xA5
676            let low_enc = low.rotate_right(4) ^ 0xA5;
677            let high_enc = high.rotate_right(4) ^ 0xA5;
678
679            dst.put_u8(low_enc);
680            dst.put_u8(high_enc);
681        }
682    }
683}
684
685#[cfg(test)]
686#[allow(clippy::unwrap_used)]
687mod tests {
688    use super::*;
689
690    #[test]
691    fn test_login7_default() {
692        let login = Login7::new();
693        assert_eq!(login.tds_version, TdsVersion::V7_4);
694        assert_eq!(login.packet_size, 4096);
695        assert!(login.option_flags2.odbc);
696    }
697
698    #[test]
699    fn test_login7_encode() {
700        let login = Login7::new()
701            .with_hostname("TESTHOST")
702            .with_sql_auth("testuser", "testpass")
703            .with_database("testdb")
704            .with_app_name("TestApp");
705
706        let encoded = login.encode();
707
708        // Check that the packet starts with a valid length
709        assert!(encoded.len() >= LOGIN7_HEADER_SIZE);
710
711        // Check TDS version at offset 4 (after length)
712        let tds_version = u32::from_le_bytes([encoded[4], encoded[5], encoded[6], encoded[7]]);
713        assert_eq!(tds_version, TdsVersion::V7_4.raw());
714    }
715
716    /// `ApplicationIntent=ReadOnly` is wired to the LOGIN7 `READONLY_INTENT`
717    /// bit (TypeFlags bit 5, MS-TDS §2.2.6.4) — pin both the flag encoding
718    /// and its position in the encoded packet (byte 26: after Length,
719    /// TDSVersion, PacketSize, ClientProgVer, ClientPID, ConnectionID,
720    /// OptionFlags1, OptionFlags2).
721    #[test]
722    fn test_login7_read_only_intent_bit() {
723        let read_only = Login7::new()
724            .with_sql_auth("u", "p")
725            .with_read_only_intent(true);
726        assert_eq!(read_only.type_flags.to_byte() & 0x20, 0x20);
727        let encoded = read_only.encode();
728        assert_eq!(
729            encoded[26] & 0x20,
730            0x20,
731            "READONLY_INTENT must be set in the encoded TypeFlags byte"
732        );
733
734        let read_write = Login7::new().with_sql_auth("u", "p");
735        assert_eq!(read_write.type_flags.to_byte() & 0x20, 0);
736        let encoded = read_write.encode();
737        assert_eq!(
738            encoded[26] & 0x20,
739            0,
740            "READONLY_INTENT must be clear by default"
741        );
742    }
743
744    #[test]
745    fn test_password_obfuscation() {
746        // Known test case: "a" should encode to specific bytes
747        let mut buf = BytesMut::new();
748        Login7::write_obfuscated_password(&mut buf, "a");
749
750        // 'a' = 0x0061 in UTF-16LE
751        // Per MS-TDS: swap nibbles FIRST, then XOR with 0xA5
752        // Low byte: 0x61 swap nibbles = 0x16, XOR 0xA5 = 0xB3
753        // High byte: 0x00 swap nibbles = 0x00, XOR 0xA5 = 0xA5
754        assert_eq!(buf.len(), 2);
755        assert_eq!(buf[0], 0xB3);
756        assert_eq!(buf[1], 0xA5);
757    }
758
759    /// Per MS-TDS §2.2.6.4, when `fExtension=1` the slot normally occupied
760    /// by `Unused` in the offset/length table becomes `ibExtension`/`cbExtension`:
761    ///   * `ibExtension` = absolute offset of a 4-byte u32.
762    ///   * `cbExtension` = 4 (fixed).
763    ///   * The u32 at that location = absolute offset of the FeatureExt data.
764    ///   * FeatureExt data = zero or more `(FeatureID u8, DataLen u32_le, Data)`
765    ///     triples, terminated by `0xFF`.
766    ///
767    /// Regression test for the bug where `ibExtension` was pointing directly
768    /// at the FeatureExt data (skipping the pointer indirection) AND the
769    /// pointer's value was off-by-4 (did not count the pointer slot itself).
770    /// Combined, SQL Server read the first four bytes of the FeatureExt blob
771    /// as a u32 offset and silently dropped the connection at LOGIN7.
772    #[test]
773    fn test_login7_feature_extension_pointer_indirection() {
774        let login = Login7::new()
775            .with_hostname("HOST")
776            .with_sql_auth("u", "p")
777            .with_database("db")
778            .with_app_name("app")
779            .with_feature(FeatureExtension {
780                feature_id: FeatureId::ColumnEncryption,
781                data: Bytes::from_static(&[0x01]),
782            });
783
784        let encoded = login.encode();
785        assert!(encoded.len() >= LOGIN7_HEADER_SIZE);
786
787        // Fixed header layout, per the `encode()` method:
788        //   0..4   length (u32)
789        //   4..8   TDS version (u32)
790        //   8..12  packet size (u32)
791        //  12..16  client program version (u32)
792        //  16..20  client PID (u32)
793        //  20..24  connection ID (u32)
794        //  24     OptionFlags1
795        //  25     OptionFlags2
796        //  26     TypeFlags
797        //  27     OptionFlags3   <-- fExtension bit lives here
798        //  28..32 client timezone (i32)
799        //  32..36 client LCID (u32)
800        //  36..   offset/length table begins
801        assert_eq!(
802            encoded[27] & 0x10,
803            0x10,
804            "option_flags3.extension bit must be set"
805        );
806
807        // ibExtension/cbExtension live in the offset/length table right after
808        // server_name's (offset, len) pair:
809        //   0: hostname (off, len)       4 bytes
810        //   1: username (off, len)       4
811        //   2: password (off, len)       4
812        //   3: app_name (off, len)       4
813        //   4: server_name (off, len)    4
814        //   5: ibExtension, cbExtension  4   <-- what we want
815        const OFFSET_TABLE_START: usize = 36;
816        const EXTENSION_SLOT: usize = OFFSET_TABLE_START + 5 * 4; // = 56
817        let ib_extension =
818            u16::from_le_bytes([encoded[EXTENSION_SLOT], encoded[EXTENSION_SLOT + 1]]) as usize;
819        let cb_extension =
820            u16::from_le_bytes([encoded[EXTENSION_SLOT + 2], encoded[EXTENSION_SLOT + 3]]);
821        assert_eq!(cb_extension, 4, "cbExtension must be 4 per MS-TDS §2.2.6.4");
822
823        // ibExtension must point to a 4-byte region still inside the packet.
824        assert!(
825            ib_extension + 4 <= encoded.len(),
826            "ibExtension out of bounds"
827        );
828
829        // Dereference the pointer. That u32 is the absolute offset of the
830        // FeatureExt data, which must also land inside the packet and must
831        // start with our FeatureId (0x04 = ColumnEncryption).
832        let feature_ext_offset = u32::from_le_bytes([
833            encoded[ib_extension],
834            encoded[ib_extension + 1],
835            encoded[ib_extension + 2],
836            encoded[ib_extension + 3],
837        ]) as usize;
838        assert!(
839            feature_ext_offset + 6 <= encoded.len(), // 1 id + 4 len + 1 data + 0xFF terminator
840            "FeatureExt offset {feature_ext_offset} out of bounds (packet is {} bytes)",
841            encoded.len()
842        );
843        assert_eq!(
844            encoded[feature_ext_offset], 0x04,
845            "first byte of FeatureExt block should be FeatureId::ColumnEncryption (0x04)"
846        );
847        let data_len = u32::from_le_bytes([
848            encoded[feature_ext_offset + 1],
849            encoded[feature_ext_offset + 2],
850            encoded[feature_ext_offset + 3],
851            encoded[feature_ext_offset + 4],
852        ]);
853        assert_eq!(data_len, 1, "ColumnEncryption version payload is 1 byte");
854        assert_eq!(
855            encoded[feature_ext_offset + 5],
856            0x01,
857            "ColumnEncryption payload is version byte 0x01"
858        );
859        assert_eq!(
860            encoded[feature_ext_offset + 6],
861            0xFF,
862            "FeatureExt stream terminator 0xFF must follow"
863        );
864
865        // Belt-and-suspenders: ibExtension must NOT point at the FeatureExt
866        // data directly (that was the original bug — it skipped the u32
867        // pointer indirection). The pointer's own offset is strictly less
868        // than the feature data's offset.
869        assert!(
870            ib_extension < feature_ext_offset,
871            "ibExtension ({ib_extension}) must point at the u32 pointer, \
872             which lives before FeatureExt data ({feature_ext_offset})"
873        );
874    }
875
876    /// MS-TDS §2.2.6.4: a small SSPI blob is carried inline in the USHORT
877    /// `cbSSPI`, with the DWORD `cbSSPILong` left 0. Pins the offset/length
878    /// table layout for the SSPI fields (sspi_offset@78, cbSSPI@80,
879    /// cbSSPILong@90 — see `encode`).
880    #[test]
881    fn test_login7_sspi_small_inline() {
882        let sspi = vec![0xABu8; 200];
883        let e = Login7::new().with_integrated_auth(sspi.clone()).encode();
884
885        let sspi_offset = u16::from_le_bytes([e[78], e[79]]) as usize;
886        let cb_sspi = u16::from_le_bytes([e[80], e[81]]);
887        let cb_sspi_long = u32::from_le_bytes([e[90], e[91], e[92], e[93]]);
888
889        assert_eq!(cb_sspi, 200, "small SSPI length goes inline in cbSSPI");
890        assert_eq!(
891            cb_sspi_long, 0,
892            "cbSSPILong is unused for a small SSPI blob"
893        );
894        assert_eq!(
895            &e[sspi_offset..sspi_offset + 200],
896            &sspi[..],
897            "SSPI bytes round-trip at sspi_offset"
898        );
899    }
900
901    /// MS-TDS §2.2.6.4: when the SSPI blob exceeds the USHORT `cbSSPI`, `cbSSPI`
902    /// MUST be the 0xFFFF sentinel and the real length goes in the DWORD
903    /// `cbSSPILong`. Also a regression for the variable-data offset arithmetic:
904    /// the FeatureExt pointer must resolve PAST a >64KB blob (offsets were
905    /// previously accumulated in u16 and wrapped around).
906    #[test]
907    fn test_login7_sspi_long_indirection() {
908        let sspi = vec![0x5Au8; 70_000];
909        let e = Login7::new()
910            .with_integrated_auth(sspi.clone())
911            .with_feature(FeatureExtension {
912                feature_id: FeatureId::ColumnEncryption,
913                data: Bytes::from_static(&[0x01]),
914            })
915            .encode();
916
917        let sspi_offset = u16::from_le_bytes([e[78], e[79]]) as usize;
918        let cb_sspi = u16::from_le_bytes([e[80], e[81]]);
919        let cb_sspi_long = u32::from_le_bytes([e[90], e[91], e[92], e[93]]);
920
921        assert_eq!(
922            cb_sspi, 0xFFFF,
923            "cbSSPI must be the 0xFFFF sentinel for a >USHORT SSPI blob"
924        );
925        assert_eq!(
926            cb_sspi_long, 70_000,
927            "cbSSPILong carries the real SSPI length"
928        );
929        assert_eq!(
930            &e[sspi_offset..sspi_offset + 70_000],
931            &sspi[..],
932            "large SSPI bytes round-trip at sspi_offset"
933        );
934
935        // The LOGIN7 Length field accounts for the full blob.
936        let total = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
937        assert_eq!(total, e.len(), "Length field matches encoded size");
938
939        // FeatureExt pointer indirection must resolve past the 70KB blob.
940        const EXTENSION_SLOT: usize = 36 + 5 * 4;
941        let ib_extension = u16::from_le_bytes([e[EXTENSION_SLOT], e[EXTENSION_SLOT + 1]]) as usize;
942        let feature_ext_offset = u32::from_le_bytes([
943            e[ib_extension],
944            e[ib_extension + 1],
945            e[ib_extension + 2],
946            e[ib_extension + 3],
947        ]) as usize;
948        assert!(
949            feature_ext_offset > 70_000,
950            "FeatureExt block must land past the SSPI blob (offset {feature_ext_offset})"
951        );
952        assert_eq!(
953            e[feature_ext_offset], 0x04,
954            "FeatureExt block starts with the ColumnEncryption id"
955        );
956        assert_eq!(
957            e[feature_ext_offset + 5],
958            0x01,
959            "ColumnEncryption version byte"
960        );
961        assert_eq!(e[feature_ext_offset + 6], 0xFF, "FeatureExt terminator");
962    }
963
964    /// The cbSSPI/cbSSPILong boundary: 0xFFFE stays inline; 0xFFFF switches to
965    /// the long form so that `cbSSPI == 0xFFFF` unambiguously means "read
966    /// cbSSPILong".
967    #[test]
968    fn test_login7_sspi_length_boundary() {
969        let e = Login7::new()
970            .with_integrated_auth(vec![0u8; 0xFFFE])
971            .encode();
972        assert_eq!(
973            u16::from_le_bytes([e[80], e[81]]),
974            0xFFFE,
975            "0xFFFE stays inline"
976        );
977        assert_eq!(
978            u32::from_le_bytes([e[90], e[91], e[92], e[93]]),
979            0,
980            "cbSSPILong unused at 0xFFFE"
981        );
982
983        let e = Login7::new()
984            .with_integrated_auth(vec![0u8; 0xFFFF])
985            .encode();
986        assert_eq!(
987            u16::from_le_bytes([e[80], e[81]]),
988            0xFFFF,
989            "0xFFFF switches to the sentinel"
990        );
991        assert_eq!(
992            u32::from_le_bytes([e[90], e[91], e[92], e[93]]),
993            0xFFFF,
994            "cbSSPILong carries the real length at the boundary"
995        );
996    }
997
998    #[test]
999    fn test_option_flags() {
1000        let flags1 = OptionFlags1::default();
1001        assert_eq!(flags1.to_byte(), 0x00);
1002
1003        let flags2 = OptionFlags2 {
1004            odbc: true,
1005            integrated_security: true,
1006            ..Default::default()
1007        };
1008        assert_eq!(flags2.to_byte(), 0x82);
1009
1010        let flags3 = OptionFlags3 {
1011            extension: true,
1012            ..Default::default()
1013        };
1014        assert_eq!(flags3.to_byte(), 0x10);
1015    }
1016}