From a144ef61b5096ea214f8ce65c2ffecc26b3b0a2c Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Fri, 22 Aug 2025 15:38:09 -0700 Subject: [PATCH 01/11] Add Json Payload Functionality for User Agent Feature Extension --- .../src/Microsoft.Data.SqlClient.csproj | 6 + .../netfx/src/Microsoft.Data.SqlClient.csproj | 6 + .../Data/SqlClient/UserAgent/UserAgentInfo.cs | 356 ++++++++++++++++++ .../SqlClient/UserAgent/UserAgentInfoDto.cs | 53 +++ .../tests/UnitTests/UserAgentInfoTests.cs | 333 ++++++++++++++++ 5 files changed, 754 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj index 97f81d7c4b..c3b06897c1 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -789,6 +789,12 @@ Microsoft\Data\SqlTypes\SqlVector.cs + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs + + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs + Resources\ResCategoryAttribute.cs diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj index 84ce735592..87e865c4ed 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj @@ -900,6 +900,12 @@ Microsoft\Data\SqlTypes\SqlVector.cs + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs + + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs + Resources\ResDescriptionAttribute.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs new file mode 100644 index 0000000000..d7aa1e1163 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs @@ -0,0 +1,356 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UserAgent; + +/// +/// Gathers driver + environment info, enforces size constraints, +/// and serializes into a UTF-8 JSON payload. +/// The spec document can be found at: https://microsoft.sharepoint-df.com/:w:/t/sqldevx/ERIWTt0zlCxLroNHyaPlKYwBI_LNSff6iy_wXZ8xX6nctQ?e=0hTJX7 +/// +internal static class UserAgentInfo +{ + /// + /// Maximum number of characters allowed for the system architecture. + /// + private const int ArchMaxChars = 16; + + /// + /// Maximum number of characters allowed for the driver name. + /// + internal const int DriverNameMaxChars = 16; + + /// + /// Maximum number of bytes allowed for the user agent json payload. + /// Payloads larger than this may be rejected by the server. + /// + internal const int JsonPayloadMaxBytes = 2047; + + /// + /// Maximum number of characters allowed for the operating system details. + /// + private const int OsDetailsMaxChars = 128; + + /// + /// Maximum number of characters allowed for the operating system type. + /// + internal const int OsTypeMaxChars = 16; + + /// + /// Maximum number of characters allowed for the driver runtime. + /// + private const int RuntimeMaxChars = 128; + + /// + /// Maximum number of characters allowed for the driver version. + /// + internal const int VersionMaxChars = 16; + + + internal const string DefaultJsonValue = "Unknown"; + internal const string DriverName = "MS-MDS"; + + private static readonly UserAgentInfoDto s_dto; + private static readonly byte[] s_userAgentCachedPayload; + + /// + /// Provides the UTF-8 encoded UserAgent JSON payload as a cached read-only memory buffer. + /// The value is computed once during process initialization and reused across all calls. + /// No re-encoding or recalculation occurs at access time, and the same memory is safely shared across all threads. + /// + public static ReadOnlyMemory UserAgentCachedJsonPayload => s_userAgentCachedPayload; + + private enum OsType + { + Windows, + Linux, + macOS, + FreeBSD, + Android, + Unknown + } + + static UserAgentInfo() + { + s_dto = BuildDto(); + s_userAgentCachedPayload = AdjustJsonPayloadSize(s_dto); + } + + /// + /// This function returns the appropriately sized json payload + /// We check the size of encoded json payload, if it is within limits we return the dto to be cached + /// other wise we drop some fields to reduce the size of the payload. + /// + /// Data Transfer Object for the json payload + /// Serialized UTF-8 encoded json payload version of DTO within size limit + internal static byte[] AdjustJsonPayloadSize(UserAgentInfoDto dto) + { + // Note: We serialize 6 fields in total: + // - 4 fields with up to 16 characters each + // - 2 fields with up to 128 characters each + // + // For estimating **on-the-wire UTF-8 size** of the serialized JSON: + // 1) For the 4 fields of 16 characters: + // - In worst case (all characters require escaping in JSON, e.g., quotes, backslashes, control chars), + // each character may expand to 2–6 bytes in the JSON string (e.g., \" = 2 bytes, \uXXXX = 6 bytes) + // - Assuming full escape with \uXXXX form (6 bytes per char): 4 × 16 × 6 = 384 bytes (extreme worst case) + // - For unescaped high-plane Unicode (e.g., emojis), UTF-8 uses up to 4 bytes per character: + // 4 × 16 × 4 = 256 bytes (UTF-8 max) + // + // Conservative max estimate for these fields = **384 bytes** + // + // 2) For the 2 fields of 128 characters: + // - Worst-case with \uXXXX escape sequences: 2 × 128 × 6 = 1,536 bytes + // - Worst-case with high Unicode: 2 × 128 × 4 = 1,024 bytes + // + // Conservative max estimate for these fields = **1,536 bytes** + // + // Combined worst-case for value content = 384 + 1536 = **1,920 bytes** + // + // 3) The rest of the serialized JSON payload (object braces, field names, quotes, colons, commas) is fixed. + // Based on measurements, it typically adds to about **81 bytes**. + // + // Final worst-case estimate for total payload on the wire (UTF-8 encoded): + // 1,920 + 81 = **2,001 bytes** + // + // This is still below our spec limit of 2,047 bytes. + // + // TDS Prelogin7 packets support up to 65,535 bytes (including headers), but many server versions impose + // stricter limits for prelogin payloads. + // + // As a safety measure: + // - If the serialized payload exceeds **10 KB**, we fallback to transmitting only essential fields: + // 'driver', 'version', and 'os.type' + // - If the payload exceeds 2,047 bytes but remains within sensible limits, we still send it, but note that + // some servers may silently drop or reject such packets — behavior we may use for future probing or diagnostics. + // - If payload exceeds 10KB even after dropping fields , we send an empty payload. + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + + // We try to send the payload if it is within the limits. + // Otherwise we drop some fields to reduce the size of the payload and try one last time + // Note: server will reject payloads larger than 2047 bytes + // Try if the payload fits the max allowed bytes + if (payload.Length <= JsonPayloadMaxBytes) + { + return payload; + } + + dto.Runtime = null; // drop Runtime + dto.Arch = null; // drop Arch + if (dto.OS != null) + { + dto.OS.Details = null; // drop OS.Details + } + + payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + if (payload.Length <= JsonPayloadMaxBytes) + { + return payload; + } + + dto.OS = null; // drop OS entirely + // Last attempt to send minimal payload driver + version only + // As per the comment in AdjustJsonPayloadSize, we know driver + version cannot be larger than the max + return JsonSerializer.SerializeToUtf8Bytes(dto, options); + } + + internal static UserAgentInfoDto BuildDto() + { + // Instantiate DTO before serializing + return new UserAgentInfoDto + { + Driver = TruncateOrDefault(DriverName, DriverNameMaxChars), + Version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars), + OS = new UserAgentInfoDto.OsInfo + { + Type = TruncateOrDefault(DetectOsType().ToString(), OsTypeMaxChars), + Details = TruncateOrDefault(DetectOsDetails(), OsDetailsMaxChars) + }, + Arch = TruncateOrDefault(DetectArchitecture(), ArchMaxChars), + Runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars) + }; + + } + + /// + /// Detects and reports whatever CPU architecture the guest OS exposes + /// + private static string DetectArchitecture() + { + try + { + // Returns the architecture of the current process (e.g., "X86", "X64", "Arm", "Arm64"). + // Note: This reflects the architecture of the running process, not the physical host system. + return RuntimeInformation.ProcessArchitecture.ToString(); + } + catch + { + // In case RuntimeInformation isn’t available or something unexpected happens + return DefaultJsonValue; + } + } + + /// + /// Retrieves the operating system details based on RuntimeInformation. + /// + private static string DetectOsDetails() + { + var osDetails = RuntimeInformation.OSDescription; + if (!string.IsNullOrWhiteSpace(osDetails)) + { + return osDetails; + } + + return DefaultJsonValue; + } + + /// + /// Detects the OS platform and returns the matching OsType enum. + /// + private static OsType DetectOsType() + { + try + { + // first we try with built-in checks (Android and FreeBSD also report Linux so they are checked first) +#if NET6_0_OR_GREATER + if (OperatingSystem.IsAndroid()) + { + return OsType.Android; + } + if (OperatingSystem.IsFreeBSD()) + { + return OsType.FreeBSD; + } + if (OperatingSystem.IsWindows()) + { + return OsType.Windows; + } + if (OperatingSystem.IsLinux()) + { + return OsType.Linux; + } + if (OperatingSystem.IsMacOS()) + { + return OsType.macOS; + } +#endif + +#if NET462 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD"))) + { + return OsType.FreeBSD; + } +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) + { + return OsType.FreeBSD; + } +#endif + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OsType.Windows; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OsType.Linux; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OsType.macOS; + } + + // Final fallback is inspecting OSDecription + // Note: This is not based on any formal specification, + // that is why we use it as a last resort. + // The string values are based on trial and error. + var desc = RuntimeInformation.OSDescription?.ToLowerInvariant() ?? ""; + if (desc.Contains("android")) + { + return OsType.Android; + } + if (desc.Contains("freebsd")) + { + return OsType.FreeBSD; + } + if (desc.Contains("windows")) + { + return OsType.Windows; + } + if (desc.Contains("linux")) + { + return OsType.Linux; + } + if (desc.Contains("darwin") || desc.Contains("mac os")) + { + return OsType.macOS; + } + } + catch + { + // swallow any unexpected errors + return OsType.Unknown; + } + return OsType.Unknown; + } + + /// + /// Returns the framework description as a string. + /// + private static string DetectRuntime() + { + // FrameworkDescription is never null, but IsNullOrWhiteSpace covers it anyway + var desc = RuntimeInformation.FrameworkDescription; + if (string.IsNullOrWhiteSpace(desc)) + { + return DefaultJsonValue; + } + + // at this point, desc is non‑null, non‑empty (after trimming) + return desc.Trim(); + } + + /// + /// Truncates a string to the specified maximum length or returns a default value if input is null or empty. + /// + /// The string value to truncate + /// Maximum number of characters allowed + /// Truncated string or default value if input is invalid + internal static string TruncateOrDefault(string jsonStringVal, int maxChars) + { + try + { + if (string.IsNullOrEmpty(jsonStringVal)) + { + return DefaultJsonValue; + } + + if (jsonStringVal.Length <= maxChars) + { + return jsonStringVal; + } + + return jsonStringVal.Substring(0, maxChars); + } + catch + { + // Silently consume all exceptions + return DefaultJsonValue; + } + } + +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs new file mode 100644 index 0000000000..2c61d1c4bb --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UserAgent; +internal class UserAgentInfoDto +{ + // Note: JSON key names are defined as constants to avoid reflection during serialization. + // This allows us to calculate their UTF-8 encoded byte sizes efficiently without instantiating + // the DTO or relying on JsonProperty attribute resolution at runtime. The small overhead of + // maintaining constants is justified by the performance and allocation savings. + + // Note: These values reflect the order of the JSON fields defined in the spec. + // The order is maintained to match the JSON payload structure. + public const string DriverJsonKey = "driver"; + public const string VersionJsonKey = "version"; + public const string OsJsonKey = "os"; + public const string ArchJsonKey = "arch"; + public const string RuntimeJsonKey = "runtime"; + + [JsonPropertyName(DriverJsonKey)] + public string Driver { get; set; } = string.Empty; + + [JsonPropertyName(VersionJsonKey)] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName(OsJsonKey)] + public OsInfo? OS { get; set; } + + [JsonPropertyName(ArchJsonKey)] + public string? Arch { get; set; } + + [JsonPropertyName(RuntimeJsonKey)] + public string? Runtime { get; set; } + + public class OsInfo + { + public const string TypeJsonKey = "type"; + public const string DetailsJsonKey = "details"; + + [JsonPropertyName(TypeJsonKey)] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName(DetailsJsonKey)] + public string? Details { get; set; } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs new file mode 100644 index 0000000000..fa5bf9752b --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -0,0 +1,333 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; +using Microsoft.Data.SqlClient.UserAgent; +using Xunit; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Unit tests for and its companion DTO. + /// Focus areas: + /// 1. Cached payload size and non-nullability + /// 2. Default expected value check for payload fields + /// 3. Payload size adjustment and field dropping(all low priority fields) + /// 4. Payload size adjustment and field dropping(drop particular low priority fields: arch, runtime and os.description) + /// 5. DTO JSON contract (key names and values) + /// 6. Combined truncation, adjustment, and serialization + /// + public class UserAgentInfoTests + { + // Cached payload is within the 2,047‑byte spec and never null + [Fact] + public void CachedPayload_IsNotNull_And_WithinSpecLimit() + { + ReadOnlyMemory payload = UserAgentInfo.UserAgentCachedJsonPayload; + Assert.False(payload.IsEmpty); + Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytes); + } + + // Cached payload contains the expected values for driver name and version + [Fact] + public void CachedPayload_Contains_Correct_DriverName_And_Version() + { + // Arrange: retrieve the raw JSON payload bytes and determine what we expect + ReadOnlyMemory payload = UserAgentInfo.UserAgentCachedJsonPayload; + Assert.False(payload.IsEmpty); // guard against empty payload + + // compute the expected driver and version + string expectedDriver = UserAgentInfo.DriverName; + string expectedVersion = ADP.GetAssemblyVersion().ToString(); + + // Act: turn the bytes back into JSON and pull out the fields + using JsonDocument document = JsonDocument.Parse(payload); + JsonElement root = document.RootElement; + string actualDriver = root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()!; + string actualVersion = root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()!; + + // Assert: the driver and version in the payload match the expected values + Assert.Equal(expectedDriver, actualDriver); + Assert.Equal(expectedVersion, actualVersion); + } + + // TruncateOrDefault respects null, empty, fit, and overflow cases + [Theory] + [InlineData(null, 5, "Unknown")] // null returns default + [InlineData("", 5, "Unknown")] // empty returns default + [InlineData("abc", 5, "abc")] // within limit unchanged + [InlineData("abcde", 5, "abcde")] // exact max chars + [InlineData("abcdef", 5, "abcde")] // overflow truncated + public void TruncateOrDefault_Behaviour(string? input, int max, string expected) + { + string actual = UserAgentInfo.TruncateOrDefault(input!, max); + Assert.Equal(expected, actual); + } + + // AdjustJsonPayloadSize drops all low‑priority fields when required + + /// + /// Verifies that AdjustJsonPayloadSize truncates the DTO’s JSON when it exceeds the maximum size. + /// High-priority fields (Driver, Version) must remain, low-priority fields (Arch, Runtime and OS) are removed + /// + [Fact] + public void AdjustJsonPayloadSize_DropAllLowPriorityFields_When_PayloadTooLarge() + { + // Arrange: create a DTO whose serialized JSON is guaranteed to exceed the max size + string huge = new string('x', 20_000); + var dto = new UserAgentInfoDto + { + Driver = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars), + Version = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars), + OS = new UserAgentInfoDto.OsInfo + { + Type = huge, + Details = huge + }, + Arch = huge, + Runtime = huge + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + string expectedDriverName = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars); + string expectedVersion = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars); + + // Capture the size before the helper mutates the DTO + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto, options); + Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); + + // Act: apply the size-adjustment helper + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + + // Assert: payload is smaller and not empty + Assert.NotEmpty(payload); + Assert.True(payload.Length < original.Length); + + // Structural checks using JsonDocument + using JsonDocument doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; + + // High-priority fields must still be present(driver name and version) + Assert.Equal(expectedDriverName, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(expectedVersion, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + // Low-priority fields should have been removed(arch and runtime) + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + + // OS block should have been removed entirely + Assert.False(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out _)); + } + + /// + /// Verifies that AdjustJsonPayloadSize truncates the DTO’s JSON when it exceeds the maximum size. + /// High-priority fields (Driver, Version) must remain, low-priority fields (Arch, Runtime and OS.details) are removed + /// Note that OS subfield(Type) is preserved, but OS.Details is dropped. + /// + [Fact] + public void AdjustJsonPayloadSize_DropSpecificPriorityFields_Excluding_OsType_When_PayloadTooLarge() + { + // Arrange: create a DTO whose serialized JSON is guaranteed to exceed the max size + string huge = new string('x', 20_000); + var dto = new UserAgentInfoDto + { + Driver = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars), + Version = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars), + OS = new UserAgentInfoDto.OsInfo + { + Type = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.OsTypeMaxChars), + Details = huge + }, + Arch = huge, + Runtime = huge + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + string expectedDriverName = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars); + string expectedVersion = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars); + string expectedOsType = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.OsTypeMaxChars); + + // Capture the size before the helper mutates the DTO + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto, options); + Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); + + // Act: apply the size-adjustment helper + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + + // Assert: payload is smaller and not empty + Assert.NotEmpty(payload); + Assert.True(payload.Length < original.Length); + + // Structural checks using JsonDocument + using JsonDocument doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; + + // High-priority fields must still be present(driver name and version) and truncated to expected length + //Assert.True(root.TryGetProperty(UserAgentInfoDto.DriverJsonKey, out _)); + Assert.Equal(expectedDriverName, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(expectedVersion, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + //Assert.True(root.TryGetProperty(UserAgentInfoDto.VersionJsonKey, out _)); + + // Low-priority fields should have been removed(arch and runtime) + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + + Assert.True(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out JsonElement os)); + Assert.True(os.TryGetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey, out JsonElement type)); + + Assert.Equal(expectedOsType, type.GetString()); + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + + } + + // DTO JSON contract - verify names and values(parameterized) + + /// + /// Verifies that UserAgentInfoDto serializes according to its JSON contract: + /// required fields always appear with correct values, optional fields + /// and the nested OS object are only emitted when non-null, + /// and all JSON property names match the defined constants. + /// + [Theory] + [InlineData("d", "v", "t", "dd", "a", "r")] + [InlineData("DeReaver", "1.2", "linux", "kernel", "", "")] + [InlineData("LongDrv", "2.0", "win", null, null, null)] + [InlineData("Driver", "Version", null, null, null, null)] // drop OsInfo entirely + public void Dto_JsonPropertyNames_MatchConstants( + string driver, + string version, + string? osType, + string? osDetails, + string? arch, + string? runtime) + { + // Arrange: build the DTO, dropping the OS object if osType is null + var dto = new UserAgentInfoDto + { + Driver = driver, + Version = version, + OS = osType == null + ? null + : new UserAgentInfoDto.OsInfo + { + Type = osType, + Details = string.IsNullOrEmpty(osDetails) ? null : osDetails + }, + Arch = string.IsNullOrEmpty(arch) ? null : arch, + Runtime = string.IsNullOrEmpty(runtime) ? null : runtime + }; + + // Arrange: configure JSON serialization to omit nulls and use exact property names + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + // Act: serialize the DTO and parse it back into a JsonDocument + string json = JsonSerializer.Serialize(dto, options); + using var doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + // Assert: required properties always present with correct values + Assert.Equal(driver, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(version, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + // Assert: Arch is only present if non-null + if (dto.Arch == null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + } + else + { + Assert.Equal(dto.Arch, root.GetProperty(UserAgentInfoDto.ArchJsonKey).GetString()); + } + + // Assert: Runtime is only present if non-null + if (dto.Runtime == null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + } + else + { + Assert.Equal(dto.Runtime, root.GetProperty(UserAgentInfoDto.RuntimeJsonKey).GetString()); + } + + // Assert: OS object may be omitted entirely + if (dto.OS == null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out _)); + } + else + { + JsonElement os = root.GetProperty(UserAgentInfoDto.OsJsonKey); + + // OS.Type must always be present when OS is not null + Assert.Equal(dto.OS.Type, os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); + + // OS.Details is optional + if (dto.OS.Details == null) + { + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + } + else + { + Assert.Equal(dto.OS.Details, os.GetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey).GetString()); + } + } + } + + // End-to-end test that combines truncation, adjustment, and serialization + [Fact] + public void EndToEnd_Truncate_Adjust_Serialize_Works() + { + string raw = new string('x', 2_000); + const int Max = 100; + + string driver = UserAgentInfo.TruncateOrDefault(raw, Max); + string version = UserAgentInfo.TruncateOrDefault(raw, Max); + string osType = UserAgentInfo.TruncateOrDefault(raw, Max); + + var dto = new UserAgentInfoDto + { + Driver = driver, + Version = version, + OS = new UserAgentInfoDto.OsInfo { Type = osType, Details = raw }, + Arch = raw, + Runtime = raw + }; + + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + string json = Encoding.UTF8.GetString(payload); + + using JsonDocument doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal(driver, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(version, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + JsonElement os = root.GetProperty(UserAgentInfoDto.OsJsonKey); + Assert.Equal(osType, os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); + } + } +} From 13090ced0da89263b7f84b604f3ed0a57e3bbd5f Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Fri, 22 Aug 2025 16:25:46 -0700 Subject: [PATCH 02/11] Update truncation null checks --- .../Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs | 9 +++++++-- .../tests/UnitTests/UserAgentInfoTests.cs | 4 +--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs index d7aa1e1163..d927155dbb 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs @@ -330,7 +330,7 @@ private static string DetectRuntime() /// The string value to truncate /// Maximum number of characters allowed /// Truncated string or default value if input is invalid - internal static string TruncateOrDefault(string jsonStringVal, int maxChars) + internal static string TruncateOrDefault(string? jsonStringVal, int maxChars) { try { @@ -339,7 +339,12 @@ internal static string TruncateOrDefault(string jsonStringVal, int maxChars) return DefaultJsonValue; } - if (jsonStringVal.Length <= maxChars) + if (maxChars <= 0) + { + return DefaultJsonValue; + } + + if (jsonStringVal!.Length <= maxChars) { return jsonStringVal; } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs index fa5bf9752b..d45c188c43 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -67,7 +67,7 @@ public void CachedPayload_Contains_Correct_DriverName_And_Version() [InlineData("abcdef", 5, "abcde")] // overflow truncated public void TruncateOrDefault_Behaviour(string? input, int max, string expected) { - string actual = UserAgentInfo.TruncateOrDefault(input!, max); + string actual = UserAgentInfo.TruncateOrDefault(input, max); Assert.Equal(expected, actual); } @@ -182,10 +182,8 @@ public void AdjustJsonPayloadSize_DropSpecificPriorityFields_Excluding_OsType_Wh JsonElement root = doc.RootElement; // High-priority fields must still be present(driver name and version) and truncated to expected length - //Assert.True(root.TryGetProperty(UserAgentInfoDto.DriverJsonKey, out _)); Assert.Equal(expectedDriverName, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); Assert.Equal(expectedVersion, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); - //Assert.True(root.TryGetProperty(UserAgentInfoDto.VersionJsonKey, out _)); // Low-priority fields should have been removed(arch and runtime) Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); From c450c61fdc05d934f3fae69efd915365f1d770e3 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Tue, 9 Sep 2025 14:27:38 -0700 Subject: [PATCH 03/11] Enable UserAgent Feature Extension --- .../Data/SqlClient/SqlInternalConnectionTds.cs | 4 ++-- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 6 ++++++ .../Data/SqlClient/SqlInternalConnectionTds.cs | 2 +- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 18 +++++++++++++++++- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 9 ++++++++- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 1871fe6087..5afed84940 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -209,7 +209,7 @@ internal bool IsDNSCachingBeforeRedirectSupported internal bool IsJsonSupportEnabled = false; // User Agent Flag - internal bool IsUserAgentEnabled = true; + internal bool IsUserAgentSupportEnabled = true; // Vector Support Flag internal bool IsVectorSupportEnabled = false; @@ -1417,7 +1417,7 @@ private void Login(ServerInfo server, TimeoutTimer timeout, string newPassword, requestedFeatures |= TdsEnums.FeatureExtension.SQLDNSCaching; requestedFeatures |= TdsEnums.FeatureExtension.JsonSupport; requestedFeatures |= TdsEnums.FeatureExtension.VectorSupport; - + #if DEBUG requestedFeatures |= TdsEnums.FeatureExtension.UserAgent; #endif diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs index 8fbeb3050c..bd27196923 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -8887,6 +8887,7 @@ private void WriteLoginData(SqlLogin rec, private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, SessionData recoverySessionData, FederatedAuthenticationFeatureExtensionData fedAuthFeatureExtensionData, + byte[] userAgentJsonPayload, bool useFeatureExt, int length, bool write = false) @@ -8939,6 +8940,11 @@ private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, length += WriteVectorSupportFeatureRequest(write); } + if ((requestedFeatures & TdsEnums.FeatureExtension.UserAgent) != 0) + { + length += WriteUserAgentFeatureRequest(userAgentJsonPayload, write); + } + length++; // for terminator if (write) { diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 2434582205..9fd0f59cb8 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -213,7 +213,7 @@ internal bool IsDNSCachingBeforeRedirectSupported internal bool IsVectorSupportEnabled = false; // User Agent Flag - internal bool IsUserAgentEnabled = true; + internal bool IsUserAgentSupportEnabled = true; // TCE flags internal byte _tceVersionSupported; diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/TdsParser.cs index d38190a359..4faea86d1b 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -29,6 +29,8 @@ using Microsoft.Data.SqlClient.DataClassification; using Microsoft.Data.SqlClient.LocalDb; using Microsoft.Data.SqlClient.Server; +using Microsoft.Data.SqlClient.UserAgent; + #if NETFRAMEWORK using Microsoft.Data.SqlTypes; #endif @@ -9035,7 +9037,15 @@ private void WriteLoginData(SqlLogin rec, } } - ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length, true); + ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + useFeatureExt, + length, + true + ); } catch (Exception e) { @@ -9054,6 +9064,7 @@ private void WriteLoginData(SqlLogin rec, private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, SessionData recoverySessionData, FederatedAuthenticationFeatureExtensionData fedAuthFeatureExtensionData, + byte[] userAgentJsonPayload, bool useFeatureExt, int length, bool write = false) @@ -9108,6 +9119,11 @@ private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, length += WriteVectorSupportFeatureRequest(write); } + if ((requestedFeatures & TdsEnums.FeatureExtension.UserAgent) != 0) + { + length += WriteUserAgentFeatureRequest(userAgentJsonPayload, write); + } + length++; // for terminator if (write) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index 6226f958a5..f039e50752 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Diagnostics; using System.Text; +using Microsoft.Data.SqlClient.UserAgent; using Microsoft.Data.SqlClient.Utilities; #nullable enable @@ -192,7 +193,13 @@ internal void TdsLogin( int feOffset = length; // calculate and reserve the required bytes for the featureEx - length = ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length); + length = ApplyFeatureExData(requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + useFeatureExt, + length + ); WriteLoginData(rec, requestedFeatures, From c8897d34dd8409628a9932c2a8ff0aa87a82ec72 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Tue, 9 Sep 2025 17:07:53 -0700 Subject: [PATCH 04/11] Add new functional tests for UserAgent FE --- .../SqlClient/SqlInternalConnectionTds.cs | 6 + .../src/Microsoft/Data/SqlClient/TdsParser.cs | 12 +- .../SqlClient/SqlInternalConnectionTds.cs | 7 +- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 3 +- .../SqlConnectionBasicTests.cs | 111 ++++++++++++++++++ .../TDS/TDS.EndPoint/ITDSServerSession.cs | 5 + .../tools/TDS/TDS.Servers/GenericTDSServer.cs | 59 +++++++++- .../TDS.Servers/GenericTDSServerSession.cs | 5 + .../tests/tools/TDS/TDS/TDSFeatureID.cs | 5 + 9 files changed, 209 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 5afed84940..533e9d70db 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -3000,6 +3000,12 @@ internal void OnFeatureExtAck(int featureId, byte[] data) IsVectorSupportEnabled = true; break; } + case TdsEnums.FEATUREEXT_USERAGENT: + { + // Unexpected ack from server but we ignore it entirely + SqlClientEventSource.Log.TryAdvancedTraceEvent(" {0}, Received feature extension acknowledgement for USERAGENTSUPPORT (ignored)", ObjectID); + break; + } default: { diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs index bd27196923..6e3af2a0a2 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -29,6 +29,8 @@ using Microsoft.Data.SqlClient.DataClassification; using Microsoft.Data.SqlClient.LocalDb; using Microsoft.Data.SqlClient.Server; +using Microsoft.Data.SqlClient.UserAgent; + #if NETFRAMEWORK using Microsoft.Data.SqlTypes; #endif @@ -8868,7 +8870,15 @@ private void WriteLoginData(SqlLogin rec, } } - ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length, true); + ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + useFeatureExt, + length, + true + ); } catch (Exception e) { diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 9fd0f59cb8..032b2ba3e2 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -3038,7 +3038,12 @@ internal void OnFeatureExtAck(int featureId, byte[] data) IsVectorSupportEnabled = true; break; } - + case TdsEnums.FEATUREEXT_USERAGENT: + { + // Unexpected ack from server but we ignore it entirely + SqlClientEventSource.Log.TryAdvancedTraceEvent(" {0}, Received feature extension acknowledgement for USERAGENTSUPPORT (ignored)", ObjectID); + break; + } default: { // Unknown feature ack diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index f039e50752..5f47011209 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -193,7 +193,8 @@ internal void TdsLogin( int feOffset = length; // calculate and reserve the required bytes for the featureEx - length = ApplyFeatureExData(requestedFeatures, + length = ApplyFeatureExData( + requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 616a8fec6f..13edff2b46 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -620,5 +620,116 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR Assert.Throws(() => connection.Open()); } } + + // Test to verify client sends a UserAgent version + // We do not receive any Ack for it from the server + [Fact] + public void TestConnWithUnackedUserAgentFeatureExtension() + { + using var server = TestTdsServer.StartTestServer(); + + // Configure the server to support UserAgent version 0x01 + server.ServerSupportedUserAgentFeatureExtVersion = 0x01; + server.EnableUserAgentFeatureExt = true; + // By design its response logic never emits an ACK + bool loginFound = false; + bool responseFound = false; + + // Inspect what the client sends in the LOGIN7 packet + server.OnLogin7Validated = loginToken => + { + var token = loginToken.FeatureExt + .OfType() + .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); + if (token != null) + { + Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); + Assert.Equal(0x1, token.Data[0]); + loginFound = true; + } + }; + + // Inspect whether the server ever sends back an ACK + server.OnAuthenticationResponseCompleted = response => + { + var ack = response + .OfType() + .SelectMany(t => t.Options) + .OfType() + .FirstOrDefault(o => o.FeatureID == TDSFeatureID.UserAgentSupport); + if (ack != null) + { + responseFound = true; + } + }; + + // Open the connection (this triggers the LOGIN7 exchange) + using var connection = new SqlConnection(server.ConnectionString); + connection.Open(); + + // Verify client did offer UserAgent + Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); + + // Verify server never acknowledged it + Assert.False(responseFound, "Server should not acknowledge UserAgent"); + + // Verify the connection itself succeeded + Assert.Equal(ConnectionState.Open, connection.State); + } + + // Test to verify the driver behaviour even if server sends an Ack + [Fact] + public void TestConnWithAckedUserAgentFeatureExtension() + { + using var server = TestTdsServer.StartTestServer(); + + // Configure the test server + server.ServerSupportedUserAgentFeatureExtVersion = 0x01; + server.EnableUserAgentFeatureExt = true; + + // Opt in to forced ACK for UserAgentSupport (no negotiation) + server.EmitUserAgentFeatureExtAck = true; + + bool loginFound = false; + bool responseFound = false; + + // Observe what the client sends in LOGIN7 + server.OnLogin7Validated = loginToken => + { + var token = loginToken.FeatureExt + .OfType() + .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); + if (token != null) + { + Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); + loginFound = true; + } + }; + + // Verify the server sent back an ACK for UserAgentSupport + server.OnAuthenticationResponseCompleted = response => + { + // Find any FeatureExtAck option with FeatureID == UserAgentSupport + var uaAckOptions = response + .OfType() + .SelectMany(t => t.Options) + .OfType() + .Where(o => o.FeatureID == TDSFeatureID.UserAgentSupport) + .ToList(); + + Assert.True(uaAckOptions.Count >= 1, "Expected an ACK for UserAgentSupport"); + responseFound = true; + }; + + // Act: open the connection which triggers the LOGIN7 exchange + using var connection = new SqlConnection(server.ConnectionString); + connection.Open(); + + // Assert: client advertised the feature, server acknowledged it, connection is healthy + Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); + Assert.True(responseFound, "Server should acknowledge UserAgent when forced"); + Assert.Equal(ConnectionState.Open, connection.State); + } + } } diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs index 9b5b7804b4..b511cbc8d2 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs @@ -93,5 +93,10 @@ public interface ITDSServerSession /// Indicates whether the client supports Vector column type /// bool IsVectorSupportEnabled { get; set; } + + /// + /// Indicates whether the client supports UserAgent Feature Extension + /// + bool IsUserAgentSupportEnabled { get; set; } } } diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs index ac04fd2f57..e31e09f3c6 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs @@ -54,16 +54,41 @@ public delegate void OnAuthenticationCompletedDelegate( /// public bool EnableVectorFeatureExt { get; set; } = false; + /// + /// Property for enabling user agent feature extension. + /// + public bool EnableUserAgentFeatureExt { get; set; } = true; + /// /// Property for setting server version for vector feature extension. /// public byte ServerSupportedVectorFeatureExtVersion { get; set; } = DefaultSupportedVectorFeatureExtVersion; + /// + /// Property for setting server version for user agent feature extension. + /// + public byte ServerSupportedUserAgentFeatureExtVersion { get; set; } = DefaultSupportedUserAgentFeatureExtVersion; + /// /// Client version for vector FeatureExtension. /// private byte _clientSupportedVectorFeatureExtVersion = 0; + /// + /// Client version for User Agent FeatureExtension. + /// + private byte _clientSupportedUserAgentFeatureExtVersion = 0; + + /// + /// Server will ACK UserAgentSupport in the login response when this property is set to true. + /// + public bool EmitUserAgentFeatureExtAck { get; set; } = false; + + /// + /// Default feature extension version supported on the server for user agent. + /// + public const byte DefaultSupportedUserAgentFeatureExtVersion = 0x01; + /// /// Session counter /// @@ -287,7 +312,14 @@ public virtual TDSMessageCollection OnLogin7Request(ITDSServerSession session, T } break; } - + case TDSFeatureID.UserAgentSupport: + { + if (EnableUserAgentFeatureExt) + { + _clientSupportedUserAgentFeatureExtVersion = ((TDSLogin7GenericOptionToken)option).Data[0]; + } + break; + } default: { // Do nothing @@ -654,6 +686,31 @@ protected virtual TDSMessageCollection OnAuthenticationCompleted(ITDSServerSessi } } + // If tests request it, force an ACK for UserAgentSupport with no negotiation + if (EmitUserAgentFeatureExtAck) + { + byte ackVersion = ServerSupportedUserAgentFeatureExtVersion; + + var data = new byte[] { ackVersion }; + var uaAck = new TDSFeatureExtAckGenericOption( + TDSFeatureID.UserAgentSupport, + (uint)data.Length, + data); + + // Reuse an existing FeatureExtAck token if present, otherwise add a new one + var featureExtAckToken = responseMessage.OfType().FirstOrDefault(); + if (featureExtAckToken == null) + { + featureExtAckToken = new TDSFeatureExtAckToken(uaAck); + responseMessage.Add(featureExtAckToken); + } + else + { + featureExtAckToken.Options.Add(uaAck); + } + } + + // Create DONE token TDSDoneToken doneToken = new TDSDoneToken(TDSDoneTokenStatusType.Final); diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs index e9e65d5f8f..986b27a4dd 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs @@ -124,6 +124,11 @@ public class GenericTDSServerSession : ITDSServerSession /// public bool IsVectorSupportEnabled { get; set; } + /// + /// Indicates whether this session supports User Agent Feature Extension + /// + public bool IsUserAgentSupportEnabled { get; set; } + #region Session Options /// diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs index 6bb6fbc8d2..258ad7e1f3 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs @@ -29,6 +29,11 @@ public enum TDSFeatureID : byte /// VectorSupport = 0x0E, + /// + /// User Agent Support + /// + UserAgentSupport = 0x0F, + /// /// End of the list /// From c0eea2bf55fa9f3a4d39f4ff71119cd974435990 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Wed, 10 Sep 2025 19:39:47 -0700 Subject: [PATCH 05/11] Update functional test to verify driver behaviour --- .../SqlClient/SqlInternalConnectionTds.cs | 6 - .../SqlClient/SqlInternalConnectionTds.cs | 5 +- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 6 + ...soft.Data.SqlClient.FunctionalTests.csproj | 2 + .../SqlConnectionBasicTests.cs | 125 +++++++++--------- 5 files changed, 71 insertions(+), 73 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 533e9d70db..ad1d18fd49 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -208,9 +208,6 @@ internal bool IsDNSCachingBeforeRedirectSupported // Json Support Flag internal bool IsJsonSupportEnabled = false; - // User Agent Flag - internal bool IsUserAgentSupportEnabled = true; - // Vector Support Flag internal bool IsVectorSupportEnabled = false; @@ -1417,10 +1414,7 @@ private void Login(ServerInfo server, TimeoutTimer timeout, string newPassword, requestedFeatures |= TdsEnums.FeatureExtension.SQLDNSCaching; requestedFeatures |= TdsEnums.FeatureExtension.JsonSupport; requestedFeatures |= TdsEnums.FeatureExtension.VectorSupport; - - #if DEBUG requestedFeatures |= TdsEnums.FeatureExtension.UserAgent; - #endif _parser.TdsLogin(login, requestedFeatures, _recoverySessionData, _fedAuthFeatureExtensionData, encrypt); } diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 032b2ba3e2..7dbb6e07de 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -212,9 +212,6 @@ internal bool IsDNSCachingBeforeRedirectSupported // Vector Support Flag internal bool IsVectorSupportEnabled = false; - // User Agent Flag - internal bool IsUserAgentSupportEnabled = true; - // TCE flags internal byte _tceVersionSupported; @@ -3040,7 +3037,9 @@ internal void OnFeatureExtAck(int featureId, byte[] data) } case TdsEnums.FEATUREEXT_USERAGENT: { + // TODO: define comment, TDS spec doesnot define an ack // Unexpected ack from server but we ignore it entirely + // TODO for tfuture if we can find and verify this log message SqlClientEventSource.Log.TryAdvancedTraceEvent(" {0}, Received feature extension acknowledgement for USERAGENTSUPPORT (ignored)", ObjectID); break; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index 5f47011209..0bcb462d1a 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -192,6 +192,12 @@ internal void TdsLogin( } int feOffset = length; + + // NOTE: This approach of pre-calculating the packet length is inefficient. + // We're making 2 passes over the data to be written. + // Instead, we should be writing everything to the buffer once, + // leaving a hole where the header length goes. + // calculate and reserve the required bytes for the featureEx length = ApplyFeatureExData( requestedFeatures, diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj index 91a5a505b9..1d9e28f7bb 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj @@ -92,6 +92,8 @@ + + diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 13edff2b46..4b51823b06 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -9,14 +9,12 @@ using System.Globalization; using System.Linq; using System.Reflection; -using System.Runtime.InteropServices; using System.Security; using System.Threading; using System.Threading.Tasks; using Microsoft.SqlServer.TDS; using Microsoft.SqlServer.TDS.FeatureExtAck; using Microsoft.SqlServer.TDS.Login7; -using Microsoft.SqlServer.TDS.PreLogin; using Microsoft.SqlServer.TDS.Servers; using Xunit; @@ -621,30 +619,46 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR } } - // Test to verify client sends a UserAgent version - // We do not receive any Ack for it from the server - [Fact] - public void TestConnWithUnackedUserAgentFeatureExtension() + // Test to verify client sends a UserAgent version + // and driver behaviour if server sends an Ack or not + [Theory] + [InlineData(false, false)] // We do not receive any Ack from the server + [InlineData(true, true)] // Server sends an Ack + public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) { using var server = TestTdsServer.StartTestServer(); // Configure the server to support UserAgent version 0x01 server.ServerSupportedUserAgentFeatureExtVersion = 0x01; server.EnableUserAgentFeatureExt = true; - // By design its response logic never emits an ACK + + // Opt in to forced ACK for UserAgentSupport (no negotiation) + server.EmitUserAgentFeatureExtAck = forceAck; + bool loginFound = false; bool responseFound = false; + // Captured from LOGIN7 as parsed by the test server + byte observedVersion = 0; + byte[] observedJsonBytes = Array.Empty(); + // Inspect what the client sends in the LOGIN7 packet server.OnLogin7Validated = loginToken => { var token = loginToken.FeatureExt - .OfType() - .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); + .OfType() + .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); + if (token != null) { Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); - Assert.Equal(0x1, token.Data[0]); + + // Layout: [0] = version byte, rest = UTF-8 JSON blob + Assert.True(token.Data.Length >= 2, "UserAgent token is too short"); + observedVersion = token.Data[0]; + Assert.Equal(0x1, observedVersion); + + observedJsonBytes = token.Data.AsSpan(1).ToArray(); loginFound = true; } }; @@ -652,84 +666,67 @@ public void TestConnWithUnackedUserAgentFeatureExtension() // Inspect whether the server ever sends back an ACK server.OnAuthenticationResponseCompleted = response => { - var ack = response + var uaAckOptions = response .OfType() .SelectMany(t => t.Options) .OfType() - .FirstOrDefault(o => o.FeatureID == TDSFeatureID.UserAgentSupport); - if (ack != null) + .Where(o => o.FeatureID == TDSFeatureID.UserAgentSupport) + .ToList(); + + if (uaAckOptions.Count > 0) { responseFound = true; } + + if (expectAck) + { + Assert.True(uaAckOptions.Count >= 1, "Expected an ACK for UserAgentSupport"); + } }; - // Open the connection (this triggers the LOGIN7 exchange) using var connection = new SqlConnection(server.ConnectionString); connection.Open(); // Verify client did offer UserAgent Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); - // Verify server never acknowledged it - Assert.False(responseFound, "Server should not acknowledge UserAgent"); + // Verify server ACK presence or absence per scenario + if (expectAck) + { + Assert.True(responseFound, "Server should acknowledge UserAgent when forced"); + } + else + { + Assert.False(responseFound, "Server should not acknowledge UserAgent"); + } // Verify the connection itself succeeded Assert.Equal(ConnectionState.Open, connection.State); - } - // Test to verify the driver behaviour even if server sends an Ack - [Fact] - public void TestConnWithAckedUserAgentFeatureExtension() - { - using var server = TestTdsServer.StartTestServer(); + // Note: Accessing UserAgentInfo via Reflection. + // We cannot use InternalsVisibleTo here because making internals visible to FunctionalTests + // causes the *.TestHarness.cs stubs to clash with the real internal types in SqlClient. + var asm = typeof(SqlConnection).Assembly; + var userAgentInfoType = + asm.GetTypes().FirstOrDefault(t => string.Equals(t.Name, "UserAgentInfo", StringComparison.Ordinal)) ?? + asm.GetTypes().FirstOrDefault(t => t.FullName?.EndsWith(".UserAgentInfo", StringComparison.Ordinal) == true); - // Configure the test server - server.ServerSupportedUserAgentFeatureExtVersion = 0x01; - server.EnableUserAgentFeatureExt = true; + Assert.True(userAgentInfoType != null, + $"Unable to find UserAgentInfo type in assembly {asm.FullName}"); - // Opt in to forced ACK for UserAgentSupport (no negotiation) - server.EmitUserAgentFeatureExtAck = true; + // Try to get the property + var prop = userAgentInfoType.GetProperty("UserAgentCachedJsonPayload", + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - bool loginFound = false; - bool responseFound = false; + Assert.True(prop != null, + "Unable to find property 'UserAgentCachedJsonPayload' on UserAgentInfo"); - // Observe what the client sends in LOGIN7 - server.OnLogin7Validated = loginToken => - { - var token = loginToken.FeatureExt - .OfType() - .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); - if (token != null) - { - Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); - loginFound = true; - } - }; + ReadOnlyMemory cachedPayload = (ReadOnlyMemory)prop.GetValue(null)!; - // Verify the server sent back an ACK for UserAgentSupport - server.OnAuthenticationResponseCompleted = response => - { - // Find any FeatureExtAck option with FeatureID == UserAgentSupport - var uaAckOptions = response - .OfType() - .SelectMany(t => t.Options) - .OfType() - .Where(o => o.FeatureID == TDSFeatureID.UserAgentSupport) - .ToList(); - - Assert.True(uaAckOptions.Count >= 1, "Expected an ACK for UserAgentSupport"); - responseFound = true; - }; - - // Act: open the connection which triggers the LOGIN7 exchange - using var connection = new SqlConnection(server.ConnectionString); - connection.Open(); + Assert.False(cachedPayload.IsEmpty); + Assert.True(observedJsonBytes.AsSpan().SequenceEqual(cachedPayload.Span), + "Observed UserAgent JSON does not match the cached payload bytes"); - // Assert: client advertised the feature, server acknowledged it, connection is healthy - Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); - Assert.True(responseFound, "Server should acknowledge UserAgent when forced"); - Assert.Equal(ConnectionState.Open, connection.State); } - } } From 26dd6f9f6ef062c037a11ba3d37111ab4b63b178 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Wed, 10 Sep 2025 19:46:41 -0700 Subject: [PATCH 06/11] Assertion update --- .../SqlConnectionBasicTests.cs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 4b51823b06..68c104d24f 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -648,19 +648,21 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) var token = loginToken.FeatureExt .OfType() .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); + - if (token != null) - { - Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); + // Test should fail if no UserAgent FE token is found + Assert.NotNull(token); - // Layout: [0] = version byte, rest = UTF-8 JSON blob - Assert.True(token.Data.Length >= 2, "UserAgent token is too short"); - observedVersion = token.Data[0]; - Assert.Equal(0x1, observedVersion); + Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); - observedJsonBytes = token.Data.AsSpan(1).ToArray(); - loginFound = true; - } + // Layout: [0] = version byte, rest = UTF-8 JSON blob + Assert.True(token.Data.Length >= 2, "UserAgent token is too short"); + + observedVersion = token.Data[0]; + Assert.Equal(0x1, observedVersion); + + observedJsonBytes = token.Data.AsSpan(1).ToArray(); + loginFound = true; }; // Inspect whether the server ever sends back an ACK From 8ad3c699d51506589a0fcb2ae1a22f920e04ffa2 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Wed, 10 Sep 2025 19:57:58 -0700 Subject: [PATCH 07/11] Remove unused flags and conditionals --- .../tests/FunctionalTests/SqlConnectionBasicTests.cs | 2 +- .../tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs | 5 ----- .../tests/tools/TDS/TDS.Servers/GenericTDSServer.cs | 10 +--------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 68c104d24f..7f23835e42 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -630,7 +630,6 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) // Configure the server to support UserAgent version 0x01 server.ServerSupportedUserAgentFeatureExtVersion = 0x01; - server.EnableUserAgentFeatureExt = true; // Opt in to forced ACK for UserAgentSupport (no negotiation) server.EmitUserAgentFeatureExtAck = forceAck; @@ -665,6 +664,7 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) loginFound = true; }; + // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds // Inspect whether the server ever sends back an ACK server.OnAuthenticationResponseCompleted = response => { diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs index b511cbc8d2..9b5b7804b4 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.EndPoint/ITDSServerSession.cs @@ -93,10 +93,5 @@ public interface ITDSServerSession /// Indicates whether the client supports Vector column type /// bool IsVectorSupportEnabled { get; set; } - - /// - /// Indicates whether the client supports UserAgent Feature Extension - /// - bool IsUserAgentSupportEnabled { get; set; } } } diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs index e31e09f3c6..0db0c13d64 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs @@ -54,11 +54,6 @@ public delegate void OnAuthenticationCompletedDelegate( /// public bool EnableVectorFeatureExt { get; set; } = false; - /// - /// Property for enabling user agent feature extension. - /// - public bool EnableUserAgentFeatureExt { get; set; } = true; - /// /// Property for setting server version for vector feature extension. /// @@ -314,10 +309,7 @@ public virtual TDSMessageCollection OnLogin7Request(ITDSServerSession session, T } case TDSFeatureID.UserAgentSupport: { - if (EnableUserAgentFeatureExt) - { - _clientSupportedUserAgentFeatureExtVersion = ((TDSLogin7GenericOptionToken)option).Data[0]; - } + _clientSupportedUserAgentFeatureExtVersion = ((TDSLogin7GenericOptionToken)option).Data[0]; break; } default: From 21840359e2b7c55c64929100df6e3b29909e74c2 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Wed, 10 Sep 2025 20:05:18 -0700 Subject: [PATCH 08/11] Remove IsUserAgentSupportEnabled flag --- .../tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs index 986b27a4dd..e9e65d5f8f 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServerSession.cs @@ -124,11 +124,6 @@ public class GenericTDSServerSession : ITDSServerSession /// public bool IsVectorSupportEnabled { get; set; } - /// - /// Indicates whether this session supports User Agent Feature Extension - /// - public bool IsUserAgentSupportEnabled { get; set; } - #region Session Options /// From 3b51844234e51ae84dc31d9e5bc2498bef664934 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Thu, 11 Sep 2025 16:41:06 -0700 Subject: [PATCH 09/11] Test cleanup and identifier update --- .../SqlClient/SqlInternalConnectionTds.cs | 10 +++- .../src/Microsoft/Data/SqlClient/TdsEnums.cs | 2 +- ...soft.Data.SqlClient.FunctionalTests.csproj | 2 - .../SqlConnectionBasicTests.cs | 57 +++---------------- .../tools/TDS/TDS.Servers/GenericTDSServer.cs | 12 +--- .../tests/tools/TDS/TDS/TDSFeatureID.cs | 2 +- 6 files changed, 19 insertions(+), 66 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 7dbb6e07de..916f7fe25d 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs @@ -3037,9 +3037,13 @@ internal void OnFeatureExtAck(int featureId, byte[] data) } case TdsEnums.FEATUREEXT_USERAGENT: { - // TODO: define comment, TDS spec doesnot define an ack - // Unexpected ack from server but we ignore it entirely - // TODO for tfuture if we can find and verify this log message + // TODO: Verify that the server sends an acknowledgment (Ack) + // using this log message in the future. + + // This Ack from the server is unexpected and is ignored completely. + // According to the TDS specification, an Ack is not defined/expected + // for this scenario. We handle it only for completeness + // and to support testing. SqlClientEventSource.Log.TryAdvancedTraceEvent(" {0}, Received feature extension acknowledgement for USERAGENTSUPPORT (ignored)", ObjectID); break; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs index 3113e19625..5101c51649 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs @@ -242,7 +242,7 @@ public enum EnvChangeType : byte public const byte FEATUREEXT_JSONSUPPORT = 0x0D; public const byte FEATUREEXT_VECTORSUPPORT = 0x0E; // TODO: re-verify if this byte competes with another feature - public const byte FEATUREEXT_USERAGENT = 0x0F; + public const byte FEATUREEXT_USERAGENT = 0x10; [Flags] public enum FeatureExtension : uint diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj index 1d9e28f7bb..91a5a505b9 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj @@ -92,8 +92,6 @@ - - diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 7f23835e42..95d9c6de2a 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -619,12 +619,12 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR } } - // Test to verify client sends a UserAgent version - // and driver behaviour if server sends an Ack or not + // Test to verify that the client sends a UserAgent version + // and driver behaves correctly even if server sent an Ack [Theory] - [InlineData(false, false)] // We do not receive any Ack from the server - [InlineData(true, true)] // Server sends an Ack - public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) + [InlineData(false)] // We do not force test server to send an Ack + [InlineData(true)] // Server is forced to send an Ack + public void TestConnWithUserAgentFeatureExtension(bool forceAck) { using var server = TestTdsServer.StartTestServer(); @@ -635,7 +635,6 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) server.EmitUserAgentFeatureExtAck = forceAck; bool loginFound = false; - bool responseFound = false; // Captured from LOGIN7 as parsed by the test server byte observedVersion = 0; @@ -665,43 +664,12 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) }; // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds - // Inspect whether the server ever sends back an ACK - server.OnAuthenticationResponseCompleted = response => - { - var uaAckOptions = response - .OfType() - .SelectMany(t => t.Options) - .OfType() - .Where(o => o.FeatureID == TDSFeatureID.UserAgentSupport) - .ToList(); - - if (uaAckOptions.Count > 0) - { - responseFound = true; - } - - if (expectAck) - { - Assert.True(uaAckOptions.Count >= 1, "Expected an ACK for UserAgentSupport"); - } - }; - using var connection = new SqlConnection(server.ConnectionString); connection.Open(); // Verify client did offer UserAgent Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); - // Verify server ACK presence or absence per scenario - if (expectAck) - { - Assert.True(responseFound, "Server should acknowledge UserAgent when forced"); - } - else - { - Assert.False(responseFound, "Server should not acknowledge UserAgent"); - } - // Verify the connection itself succeeded Assert.Equal(ConnectionState.Open, connection.State); @@ -713,22 +681,15 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck, bool expectAck) asm.GetTypes().FirstOrDefault(t => string.Equals(t.Name, "UserAgentInfo", StringComparison.Ordinal)) ?? asm.GetTypes().FirstOrDefault(t => t.FullName?.EndsWith(".UserAgentInfo", StringComparison.Ordinal) == true); - Assert.True(userAgentInfoType != null, - $"Unable to find UserAgentInfo type in assembly {asm.FullName}"); - + Assert.NotNull(userAgentInfoType); + // Try to get the property var prop = userAgentInfoType.GetProperty("UserAgentCachedJsonPayload", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - - Assert.True(prop != null, - "Unable to find property 'UserAgentCachedJsonPayload' on UserAgentInfo"); + Assert.NotNull(prop); ReadOnlyMemory cachedPayload = (ReadOnlyMemory)prop.GetValue(null)!; - - Assert.False(cachedPayload.IsEmpty); - Assert.True(observedJsonBytes.AsSpan().SequenceEqual(cachedPayload.Span), - "Observed UserAgent JSON does not match the cached payload bytes"); - + Assert.Equal(cachedPayload.ToArray(), observedJsonBytes.ToArray()); } } } diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs index 0db0c13d64..1fdb90d608 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTDSServer.cs @@ -69,11 +69,6 @@ public delegate void OnAuthenticationCompletedDelegate( /// private byte _clientSupportedVectorFeatureExtVersion = 0; - /// - /// Client version for User Agent FeatureExtension. - /// - private byte _clientSupportedUserAgentFeatureExtVersion = 0; - /// /// Server will ACK UserAgentSupport in the login response when this property is set to true. /// @@ -307,11 +302,7 @@ public virtual TDSMessageCollection OnLogin7Request(ITDSServerSession session, T } break; } - case TDSFeatureID.UserAgentSupport: - { - _clientSupportedUserAgentFeatureExtVersion = ((TDSLogin7GenericOptionToken)option).Data[0]; - break; - } + default: { // Do nothing @@ -702,7 +693,6 @@ protected virtual TDSMessageCollection OnAuthenticationCompleted(ITDSServerSessi } } - // Create DONE token TDSDoneToken doneToken = new TDSDoneToken(TDSDoneTokenStatusType.Final); diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs index 258ad7e1f3..7681b72ac1 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSFeatureID.cs @@ -32,7 +32,7 @@ public enum TDSFeatureID : byte /// /// User Agent Support /// - UserAgentSupport = 0x0F, + UserAgentSupport = 0x10, /// /// End of the list From d67310474205dcbdd8a645887c22191bf7d6b5dd Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Mon, 6 Oct 2025 17:10:48 -0700 Subject: [PATCH 10/11] Fix server side throw issue --- .../SqlConnectionBasicTests.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 95d9c6de2a..66b041ac35 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -646,20 +646,19 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) var token = loginToken.FeatureExt .OfType() .FirstOrDefault(t => t.FeatureID == TDSFeatureID.UserAgentSupport); - - - // Test should fail if no UserAgent FE token is found - Assert.NotNull(token); - - Assert.Equal((byte)TDSFeatureID.UserAgentSupport, (byte)token.FeatureID); + if (token == null) + { + return; + } - // Layout: [0] = version byte, rest = UTF-8 JSON blob - Assert.True(token.Data.Length >= 2, "UserAgent token is too short"); - - observedVersion = token.Data[0]; - Assert.Equal(0x1, observedVersion); + var data = token.Data; + if (data == null || data.Length < 2) + { + return; + } - observedJsonBytes = token.Data.AsSpan(1).ToArray(); + observedVersion = data[0]; + observedJsonBytes = data.AsSpan(1).ToArray(); loginFound = true; }; @@ -667,12 +666,13 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) using var connection = new SqlConnection(server.ConnectionString); connection.Open(); - // Verify client did offer UserAgent - Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); - // Verify the connection itself succeeded Assert.Equal(ConnectionState.Open, connection.State); + // Verify client did offer UserAgent + Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); + Assert.Equal(0x1, observedVersion); + // Note: Accessing UserAgentInfo via Reflection. // We cannot use InternalsVisibleTo here because making internals visible to FunctionalTests // causes the *.TestHarness.cs stubs to clash with the real internal types in SqlClient. From d494b6635eb52ff1374fb3d63bfe91c6f26c3306 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Wed, 8 Oct 2025 15:56:13 -0700 Subject: [PATCH 11/11] Add useragent payload in parser --- .../Microsoft/Data/SqlClient/TdsParser.SSPI.cs | 10 +++++++++- .../src/Microsoft/Data/SqlClient/TdsParser.cs | 18 +++++++++++++++++- .../SimulatedServerTests/ConnectionTests.cs | 15 ++++++++++++--- .../tools/TDS/TDS.Servers/GenericTdsServer.cs | 10 ---------- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.SSPI.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.SSPI.cs index 6226f958a5..2881f70ad4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.SSPI.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.SSPI.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Diagnostics; using System.Text; +using Microsoft.Data.SqlClient.UserAgent; using Microsoft.Data.SqlClient.Utilities; #nullable enable @@ -192,7 +193,14 @@ internal void TdsLogin( int feOffset = length; // calculate and reserve the required bytes for the featureEx - length = ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length); + length = ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + useFeatureExt, + length + ); WriteLoginData(rec, requestedFeatures, diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index 35df415a7c..b1e933f6e2 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -29,6 +29,8 @@ using Microsoft.Data.SqlClient.DataClassification; using Microsoft.Data.SqlClient.LocalDb; using Microsoft.Data.SqlClient.Server; +using Microsoft.Data.SqlClient.UserAgent; + #if NETFRAMEWORK using Microsoft.Data.SqlTypes; #endif @@ -9233,7 +9235,15 @@ private void WriteLoginData(SqlLogin rec, } } - ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length, true); + ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + useFeatureExt, + length, + true + ); } catch (Exception e) { @@ -9252,6 +9262,7 @@ private void WriteLoginData(SqlLogin rec, private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, SessionData recoverySessionData, FederatedAuthenticationFeatureExtensionData fedAuthFeatureExtensionData, + byte[] userAgentJsonPayload, bool useFeatureExt, int length, bool write = false) @@ -9306,6 +9317,11 @@ private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, length += WriteVectorSupportFeatureRequest(write); } + if ((requestedFeatures & TdsEnums.FeatureExtension.UserAgent) != 0) + { + length += WriteUserAgentFeatureRequest(userAgentJsonPayload, write); + } + length++; // for terminator if (write) { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index 34f3891263..0a1e83bb2e 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -15,6 +15,7 @@ using Microsoft.SqlServer.TDS; using Microsoft.SqlServer.TDS.FeatureExtAck; using Microsoft.SqlServer.TDS.Login7; +using Microsoft.SqlServer.TDS.PreLogin; using Microsoft.SqlServer.TDS.Servers; using Xunit; @@ -835,7 +836,8 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR [InlineData(true)] // Server is forced to send an Ack public void TestConnWithUserAgentFeatureExtension(bool forceAck) { - using var server = TestTdsServer.StartTestServer(); + using var server = new TdsServer(); + server.Start(); // Configure the server to support UserAgent version 0x01 server.ServerSupportedUserAgentFeatureExtVersion = 0x01; @@ -863,7 +865,7 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) var data = token.Data; if (data == null || data.Length < 2) { - return; + return; } observedVersion = data[0]; @@ -871,8 +873,15 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) loginFound = true; }; + // Connect to the test TDS server. + var connStr = new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + }.ConnectionString; + // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds - using var connection = new SqlConnection(server.ConnectionString); + using var connection = new SqlConnection(connStr); connection.Open(); // Verify the connection itself succeeded diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs index 22048be305..c3581753d2 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs @@ -132,16 +132,6 @@ public GenericTdsServer(T arguments, QueryEngine queryEngine) /// public int PreLoginCount => _preLoginCount; - /// - /// Property for setting server version for vector feature extension. - /// - public bool EnableVectorFeatureExt { get; set; } = false; - - /// - /// Property for setting server version for vector feature extension. - /// - public byte ServerSupportedVectorFeatureExtVersion { get; set; } = DefaultSupportedVectorFeatureExtVersion; - public OnAuthenticationCompletedDelegate OnAuthenticationResponseCompleted { private get; set; } public OnLogin7ValidatedDelegate OnLogin7Validated { private get; set; }