Skip to content

Commit e1a0844

Browse files
committed
Merged PR 7346: Sync dev with main
Sync dev with main Related work items: #40377, #40829, #40924, #41302, #41412, #41497, #41728, #41762, #41830, #41851, #42218, #42220, #42221, #42278, #43144, #43310, #43311, #43313, #43314, #43315, #43316, #43861, #43862, #43863, #43976, #43988
2 parents 72945bf + 249f5dd commit e1a0844

25 files changed

Lines changed: 2056 additions & 169 deletions

File tree

.pipeline/templates/build-template.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ steps:
135135
workingDirectory: "$(Build.SourcesDirectory)"
136136
displayName: Save code coverage in lcov format (mssql-tds package only)
137137

138+
- ${{ if eq(parameters.osType, 'Linux') }}:
139+
- bash: |
140+
echo "Running mssql-py-core Rust unit tests..."
141+
cd mssql-py-core && cargo test --frozen -- --show-output
142+
displayName: Run Rust unit tests (mssql-py-core)
143+
condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))
144+
138145
- ${{ if eq(parameters.osType, 'Linux') }}:
139146
- bash: |
140147
echo "Running Python tests for mssql-py-core..."

docs/ssrp-plan.md

Lines changed: 278 additions & 0 deletions
Large diffs are not rendered by default.

mssql-mock-tds-py/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,19 @@ pub struct PyConnectionInfo {
6565
/// Whether the client authenticated successfully
6666
#[pyo3(get)]
6767
pub authenticated: bool,
68+
/// User Agent sent by the client
69+
#[pyo3(get)]
70+
pub user_agent: Option<String>,
6871
}
6972

7073
#[pymethods]
7174
impl PyConnectionInfo {
7275
fn __repr__(&self) -> String {
7376
format!(
74-
"ConnectionInfo(addr='{}', authenticated={}, has_token={})",
77+
"ConnectionInfo(addr='{}', authenticated={}, user_agent='{:?}', has_token={})",
7578
self.addr,
7679
self.authenticated,
80+
self.user_agent,
7781
self.access_token.is_some()
7882
)
7983
}
@@ -239,6 +243,7 @@ impl PyMockTdsServer {
239243
addr: info.addr.to_string(),
240244
access_token: info.received_token_as_string(),
241245
authenticated: info.authenticated,
246+
user_agent: info.user_agent.clone(),
242247
})
243248
.collect()
244249
}))

mssql-mock-tds/src/protocol.rs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ pub enum TokenType {
158158

159159
/// FedAuth feature extension ID
160160
pub const FEATURE_EXT_FEDAUTH: u8 = 0x02;
161+
pub const FEATURE_EXT_USER_AGENT: u8 = 0x10;
161162
/// FedAuth terminator
162163
pub const FEATURE_EXT_TERMINATOR: u8 = 0xFF;
163164

@@ -172,6 +173,8 @@ pub struct Login7AuthInfo {
172173
pub fedauth_library: u8,
173174
/// Server name from Login7 packet (the data source string sent by client)
174175
pub server_name: Option<String>,
176+
/// User Agent string from Login7 packet (if present)
177+
pub user_agent: Option<String>,
175178
}
176179

177180
/// Parse Login7 packet to extract FedAuth feature extension with access token
@@ -322,7 +325,23 @@ pub fn parse_login7_auth(packet_data: &[u8]) -> Login7AuthInfo {
322325
}
323326
}
324327
}
325-
break;
328+
} else if feature_id == FEATURE_EXT_USER_AGENT && i + 5 + feat_len <= data.len() {
329+
let feat_data = &data[i + 5..i + 5 + feat_len];
330+
if feat_data.len().is_multiple_of(2) {
331+
let u16_chars: Vec<u16> = feat_data
332+
.chunks_exact(2)
333+
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
334+
.collect();
335+
if let Ok(user_agent) = String::from_utf16(&u16_chars) {
336+
debug!("Parsed UserAgent from Login7 (len: {})", user_agent.len());
337+
auth_info.user_agent = Some(user_agent);
338+
}
339+
} else {
340+
debug!(
341+
"Skipping UserAgent FeatureExtension due to odd data length: {}",
342+
feat_len
343+
);
344+
}
326345
}
327346

328347
// Skip to next feature entry
@@ -607,8 +626,15 @@ pub fn build_query_result(response: &crate::query_response::QueryResponse) -> By
607626
for col in &response.columns {
608627
result.put_u32_le(0); // UserType
609628
result.put_u16_le(0x0000); // Flags: not nullable, no special flags
610-
result.put_u8(col.data_type.tds_type_code()); // Type code
611-
result.put_u8(col.data_type.max_length()); // Max length
629+
result.put_u8(col.data_type.tds_type_code());
630+
if col.data_type == crate::query_response::SqlDataType::NVarChar {
631+
// Required to support string responses (e.g., @@USERAGENT).
632+
// TDS ColMetadata mandates a 5-byte collation suffix for variable-length types.
633+
result.put_u16_le(8000); // NVARCHAR(4000) max byte capacity
634+
result.put_slice(&[0x09, 0x04, 0xD0, 0x00, 0x34]); // SQL_Latin1_General_CP1_CI_AS
635+
} else {
636+
result.put_u8(col.data_type.max_length());
637+
}
612638

613639
// Column name (UTF-16LE)
614640
let name_len = col.name.chars().count() as u8;
@@ -883,4 +909,58 @@ mod tests {
883909
// After header, should have EnvChange token (0xE3)
884910
assert_eq!(response[PACKET_HEADER_SIZE], TokenType::EnvChange as u8);
885911
}
912+
913+
#[test]
914+
fn test_parse_login7_user_agent() {
915+
let user_agent = "TestAgent";
916+
let utf16_bytes: Vec<u8> = user_agent
917+
.encode_utf16()
918+
.flat_map(|ch| ch.to_le_bytes().into_iter())
919+
.collect();
920+
921+
let mut payload = BytesMut::zeroed(100);
922+
923+
// OptionFlags3 at byte 27: set FeatureExt bit (0x10)
924+
payload[27] = 0x10;
925+
926+
// FeatureExt table entry at bytes 56-57 (points to offset 60)
927+
payload[56] = 60;
928+
payload[57] = 0;
929+
930+
// DWORD at byte 60 pointing to the actual feature data at byte 64
931+
payload[60..64].copy_from_slice(&64u32.to_le_bytes());
932+
933+
// Truncate the buffer at 64 so we can append the feature bytes directly
934+
payload.truncate(64);
935+
936+
// Append UserAgent feature
937+
payload.put_u8(FEATURE_EXT_USER_AGENT); // 0x10
938+
payload.put_u32_le(utf16_bytes.len() as u32);
939+
payload.put_slice(&utf16_bytes);
940+
payload.put_u8(FEATURE_EXT_TERMINATOR); // 0xFF
941+
942+
let result = parse_login7_auth(&payload);
943+
assert_eq!(result.user_agent.unwrap(), "TestAgent");
944+
}
945+
946+
#[test]
947+
fn test_parse_login7_odd_length_user_agent() {
948+
let mut payload = BytesMut::zeroed(100);
949+
payload[27] = 0x10; // OptionFlags3 (FeatureExt bit)
950+
payload[56] = 60; // Option offset
951+
payload[57] = 0;
952+
payload[60..64].copy_from_slice(&64u32.to_le_bytes()); // Feature offset
953+
payload.truncate(64);
954+
955+
// FeatureId (1 byte) = 2 (UserAgent)
956+
// FeatureDataLen (4 bytes) = 5 (odd length)
957+
// FeatureData (5 bytes) = [1, 2, 3, 4, 5]
958+
payload.put_u8(FEATURE_EXT_USER_AGENT); // 2
959+
payload.put_u32_le(5);
960+
payload.put_slice(&[1, 2, 3, 4, 5]);
961+
payload.put_u8(FEATURE_EXT_TERMINATOR); // 0xFF
962+
963+
let result = parse_login7_auth(&payload);
964+
assert_eq!(result.user_agent, None);
965+
}
886966
}

mssql-mock-tds/src/query_response.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub enum SqlDataType {
1717
Int,
1818
/// BigInt - 8 byte integer
1919
BigInt,
20+
/// NVarChar - UTF16 string
21+
NVarChar,
2022
}
2123

2224
impl SqlDataType {
@@ -27,6 +29,7 @@ impl SqlDataType {
2729
SqlDataType::SmallInt => 0x26, // IntN with length 2
2830
SqlDataType::Int => 0x26, // IntN with length 4
2931
SqlDataType::BigInt => 0x26, // IntN with length 8
32+
SqlDataType::NVarChar => 0xE7, // NVarCharType
3033
}
3134
}
3235

@@ -37,6 +40,7 @@ impl SqlDataType {
3740
SqlDataType::SmallInt => 2,
3841
SqlDataType::Int => 4,
3942
SqlDataType::BigInt => 8,
43+
SqlDataType::NVarChar => 255, // Handled specially
4044
}
4145
}
4246
}
@@ -48,6 +52,7 @@ pub enum ColumnValue {
4852
SmallInt(i16),
4953
Int(i32),
5054
BigInt(i64),
55+
NVarChar(String),
5156
Null,
5257
}
5358

@@ -59,6 +64,7 @@ impl ColumnValue {
5964
ColumnValue::SmallInt(_) => SqlDataType::SmallInt,
6065
ColumnValue::Int(_) => SqlDataType::Int,
6166
ColumnValue::BigInt(_) => SqlDataType::BigInt,
67+
ColumnValue::NVarChar(_) => SqlDataType::NVarChar,
6268
ColumnValue::Null => SqlDataType::Int, // Default to Int for NULL
6369
}
6470
}
@@ -82,6 +88,14 @@ impl ColumnValue {
8288
buf.put_u8(8); // Length indicator
8389
buf.put_i64_le(*v);
8490
}
91+
ColumnValue::NVarChar(v) => {
92+
let utf16_bytes: Vec<u8> = v
93+
.encode_utf16()
94+
.flat_map(|ch| ch.to_le_bytes().into_iter())
95+
.collect();
96+
buf.put_u16_le(utf16_bytes.len() as u16);
97+
buf.put_slice(&utf16_bytes);
98+
}
8599
ColumnValue::Null => {
86100
buf.put_u8(0); // Length 0 means NULL for IntN
87101
}
@@ -196,3 +210,61 @@ impl Default for QueryRegistry {
196210
Self::new()
197211
}
198212
}
213+
214+
#[cfg(test)]
215+
mod tests {
216+
use super::*;
217+
218+
#[test]
219+
fn test_sql_data_types() {
220+
assert_eq!(SqlDataType::TinyInt.tds_type_code(), 0x26);
221+
assert_eq!(SqlDataType::SmallInt.tds_type_code(), 0x26);
222+
assert_eq!(SqlDataType::Int.tds_type_code(), 0x26);
223+
assert_eq!(SqlDataType::BigInt.tds_type_code(), 0x26);
224+
assert_eq!(SqlDataType::NVarChar.tds_type_code(), 0xE7);
225+
226+
assert_eq!(SqlDataType::TinyInt.max_length(), 1);
227+
assert_eq!(SqlDataType::SmallInt.max_length(), 2);
228+
assert_eq!(SqlDataType::Int.max_length(), 4);
229+
assert_eq!(SqlDataType::BigInt.max_length(), 8);
230+
assert_eq!(SqlDataType::NVarChar.max_length(), 255);
231+
}
232+
233+
#[test]
234+
fn test_column_value_write() {
235+
let mut buf = bytes::BytesMut::new();
236+
237+
let null_val = ColumnValue::Null;
238+
assert_eq!(null_val.data_type(), SqlDataType::Int);
239+
null_val.write_to_buffer(&mut buf);
240+
assert_eq!(&buf[..], &[0]);
241+
buf.clear();
242+
243+
let nvarchar_val = ColumnValue::NVarChar("test".to_string());
244+
assert_eq!(nvarchar_val.data_type(), SqlDataType::NVarChar);
245+
nvarchar_val.write_to_buffer(&mut buf);
246+
// length is 4 u16 chars => 8 bytes, so 0x08 0x00 is the length followed by the utf16 LE bytes
247+
assert_eq!(&buf[0..2], &[8, 0]);
248+
buf.clear();
249+
250+
let int_val = ColumnValue::Int(1);
251+
assert_eq!(int_val.data_type(), SqlDataType::Int);
252+
int_val.write_to_buffer(&mut buf);
253+
assert_eq!(&buf[..], &[4, 1, 0, 0, 0]);
254+
}
255+
256+
#[test]
257+
fn test_select_nvarchar_type() {
258+
let mut registry = QueryRegistry::new();
259+
registry.register(
260+
"SELECT 'test'",
261+
QueryResponse::new(
262+
vec![ColumnDefinition::new("", SqlDataType::NVarChar)],
263+
vec![Row::new(vec![ColumnValue::NVarChar("test".to_string())])],
264+
),
265+
);
266+
let resp = registry.get("SELECT 'test'").unwrap();
267+
assert_eq!(resp.columns.len(), 1);
268+
assert_eq!(resp.rows.len(), 1);
269+
}
270+
}

mssql-mock-tds/src/server.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ pub struct ConnectionProcessor {
5353
is_authenticated: bool,
5454
/// Access token received during FedAuth authentication (if any)
5555
received_token: Option<Vec<u8>>,
56+
/// User Agent received during authentication (if any)
57+
pub user_agent: Option<String>,
5658
/// Reference to the shared query registry
5759
query_registry: Arc<Mutex<QueryRegistry>>,
5860
/// Packet buffer for this connection
@@ -68,6 +70,7 @@ impl ConnectionProcessor {
6870
addr,
6971
is_authenticated: false,
7072
received_token: None,
73+
user_agent: None,
7174
query_registry,
7275
buffer: BytesMut::with_capacity(4096),
7376
redirection: None,
@@ -84,6 +87,7 @@ impl ConnectionProcessor {
8487
addr,
8588
is_authenticated: false,
8689
received_token: None,
90+
user_agent: None,
8791
query_registry,
8892
buffer: BytesMut::with_capacity(4096),
8993
redirection,
@@ -166,6 +170,7 @@ impl ConnectionProcessor {
166170
// Parse Login7 packet body (skip header) for authentication info
167171
let packet_body = &packet_data[PACKET_HEADER_SIZE..];
168172
let auth_info = parse_login7_auth(packet_body);
173+
self.user_agent = auth_info.user_agent.clone();
169174

170175
// Log the server name sent by client (important for verifying redirection behavior)
171176
if let Some(ref server_name) = auth_info.server_name {
@@ -329,6 +334,8 @@ pub struct ConnectionInfo {
329334
pub received_token: Option<Vec<u8>>,
330335
/// Whether the client authenticated successfully
331336
pub authenticated: bool,
337+
/// User Agent received during authentication (if any)
338+
pub user_agent: Option<String>,
332339
}
333340

334341
impl ConnectionInfo {
@@ -346,6 +353,10 @@ impl ConnectionInfo {
346353
}
347354
})
348355
}
356+
/// Get the user agent received during authentication
357+
pub fn received_user_agent(&self) -> Option<String> {
358+
self.user_agent.clone()
359+
}
349360
}
350361

351362
impl ConnectionStore {
@@ -361,6 +372,7 @@ impl ConnectionStore {
361372
addr: processor.addr(),
362373
received_token: processor.received_token().map(|t| t.to_vec()),
363374
authenticated: processor.is_authenticated(),
375+
user_agent: processor.user_agent.clone(),
364376
};
365377
self.connections.insert(processor.addr(), info);
366378
}
@@ -1007,6 +1019,8 @@ async fn handle_connection(
10071019

10081020
PacketType::Login7 => {
10091021
debug!("Handling Login7");
1022+
let packet_body = &packet_data[PACKET_HEADER_SIZE..];
1023+
let _auth_info = parse_login7_auth(packet_body);
10101024
is_authenticated = true;
10111025

10121026
// Build response with LoginAck + EnvChange + Done

mssql-py-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mssql-py-core"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
edition = "2024"
55
publish = false
66

mssql-py-core/src/bulkcopy.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,17 +1230,12 @@ impl PythonRowAdapter {
12301230
}
12311231

12321232
// Default to DATETIME format
1233-
// Calculate time in 1/300th seconds
1234-
let total_ms = (hour as u64) * 3_600_000
1235-
+ (minute as u64) * 60_000
1236-
+ (second as u64) * 1_000
1237-
+ (microsecond as u64) / 1_000;
1238-
1239-
let time_ticks = ((total_ms * 300) / 1000) as u32;
1233+
let (final_days, time_ticks) =
1234+
crate::types::datetime_to_ticks(days, hour, minute, second, microsecond)?;
12401235

12411236
Ok(ColumnValues::DateTime(
12421237
mssql_tds::datatypes::column_values::SqlDateTime {
1243-
days,
1238+
days: final_days,
12441239
time: time_ticks,
12451240
},
12461241
))

0 commit comments

Comments
 (0)