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 36de19a385..6203180e9c 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/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 8e1da46973..4e94c9eee3 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,7 +208,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; @@ -1432,6 +1432,8 @@ private void Login(ServerInfo server, TimeoutTimer timeout, string newPassword, requestedFeatures |= TdsEnums.FeatureExtension.SQLDNSCaching; requestedFeatures |= TdsEnums.FeatureExtension.JsonSupport; requestedFeatures |= TdsEnums.FeatureExtension.VectorSupport; + requestedFeatures |= TdsEnums.FeatureExtension.UserAgent; + #if DEBUG requestedFeatures |= TdsEnums.FeatureExtension.UserAgent; 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 dc53d7a53b..3fc636f557 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 @@ -30,6 +30,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 @@ -8875,8 +8877,16 @@ private void WriteLoginData(SqlLogin rec, _physicalStateObj.WriteByteArray(encryptedChangePassword, encryptedChangePasswordLengthInBytes, 0); } } - - ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length, true); + + ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.GetCachedPayload(), + useFeatureExt, + length, + true + ); } catch (Exception e) { @@ -8895,12 +8905,13 @@ private void WriteLoginData(SqlLogin rec, private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, SessionData recoverySessionData, FederatedAuthenticationFeatureExtensionData fedAuthFeatureExtensionData, + byte[] userAgentJsonPayload, bool useFeatureExt, int length, bool write = false) { if (useFeatureExt) - { + { if ((requestedFeatures & TdsEnums.FeatureExtension.SessionRecovery) != 0) { length += WriteSessionRecoveryFeatureRequest(recoverySessionData, write); @@ -8947,6 +8958,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.csproj b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj index 0c2fe65f93..f0f61097d7 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/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlInternalConnectionTds.cs index 7ca1b68474..c3d72923be 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,7 +212,7 @@ internal bool IsDNSCachingBeforeRedirectSupported internal bool IsVectorSupportEnabled = false; // User Agent Flag - internal bool IsUserAgentEnabled = true; + internal bool IsUserAgentSupportEnabled = true; // TCE flags internal byte _tceVersionSupported; @@ -1438,10 +1438,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); } @@ -3072,6 +3069,25 @@ internal void OnFeatureExtAck(int featureId, byte[] data) break; } + case TdsEnums.FEATUREEXT_USERAGENT: + { + // Note: We do not expect an ACK for USERAGENT feature extension, but if we receive it, we will log it. + SqlClientEventSource.Log.TryAdvancedTraceEvent(" {0}, Received feature extension acknowledgement for USERAGENT", ObjectID); + if (data.Length != 1) + { + SqlClientEventSource.Log.TryTraceEvent(" {0}, Unknown token for USERAGENT", ObjectID); + throw SQL.ParsingError(ParsingErrorState.CorruptedTdsStream); + } + byte userAgentSupportVersion = data[0]; + if (userAgentSupportVersion == 0 || userAgentSupportVersion > TdsEnums.SUPPORTED_USER_AGENT_VERSION) + { + SqlClientEventSource.Log.TryTraceEvent(" {0}, Invalid version number {1} for USERAGENT, Max supported version is {2}", ObjectID, userAgentSupportVersion, TdsEnums.SUPPORTED_USER_AGENT_VERSION); + throw SQL.ParsingError(ParsingErrorState.CorruptedTdsStream); + } + IsUserAgentSupportEnabled = true; + break; + } + default: { // Unknown feature ack 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 d26bd0557c..b57071832b 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 @@ -30,6 +30,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 @@ -9074,8 +9076,16 @@ private void WriteLoginData(SqlLogin rec, _physicalStateObj.WriteByteArray(encryptedChangePassword, encryptedChangePasswordLengthInBytes, 0); } } - - ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length, true); + + ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.GetCachedPayload(), + useFeatureExt, + length, + true + ); } catch (Exception e) { @@ -9094,6 +9104,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) @@ -9148,6 +9159,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..68a0e7fac4 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 @@ -191,8 +192,17 @@ internal void TdsLogin( } int feOffset = length; + // TODO: User Agent Json Payload will go here + byte[] emptyBytes = new byte[0]; // calculate and reserve the required bytes for the featureEx - length = ApplyFeatureExData(requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, useFeatureExt, length); + length = ApplyFeatureExData( + requestedFeatures, + recoverySessionData, + fedAuthFeatureExtensionData, + UserAgentInfo.GetCachedPayload(), + useFeatureExt, + length + ); WriteLoginData(rec, requestedFeatures, 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..ca5c86fcd8 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Data.Common; + +#if WINDOWS +using System.Management; +#endif + +namespace Microsoft.Data.SqlClient.UserAgent +{ + /// + /// Gathers driver + environment info, enforces size constraints, + /// and serializes into a UTF-8 JSON payload. + /// + internal static class UserAgentInfo + { + /// + /// Maximum number of characters allowed for the driver name. + /// + private const int DriverNameMaxChars = 16; + + /// + /// Maximum number of characters allowed for the driver version. + /// + private const int VersionMaxChars = 16; + + /// + /// Maximum number of characters allowed for the operating system type. + /// + private const int OsTypeMaxChars = 16; + + /// + /// Maximum number of characters allowed for the operating system details. + /// + private const int OsDetailsMaxChars = 128; + + /// + /// Maximum number of characters allowed for the system architecture. + /// + private const int ArchMaxChars = 16; + + /// + /// Maximum number of characters allowed for the driver runtime. + /// + private const int RuntimeMaxChars = 128; + + /// + /// Maximum number of bytes allowed for the user agent json payload. + /// payloads larger than this may be rejected by the server. + /// + public const int JsonPayloadMaxBytesSpec = 2047; + + /// + /// Maximum number of bytes allowed before we drop multiple fields + /// and only send bare minimum useragent info. + /// + public const int UserAgentPayloadMaxBytes = 10000; + + + private const string DefaultJsonValue = "Unknown"; + private const string DefaultDriverName = "MS-MDS"; + + // JSON Payload for UserAgent + private static readonly string driverName; + private static readonly string version; + private static readonly string osType; + private static readonly string osDetails; + private static readonly string architecture; + private static readonly string runtime; + private static readonly byte[] _cachedPayload; + + private enum OsType + { + Windows, + Linux, + macOS, + FreeBSD, + Android, + Unknown + } + + // P/Invoke signature for glibc detection + [DllImport("libc", EntryPoint = "gnu_get_libc_version", CallingConvention = CallingConvention.Cdecl)] + private static extern nint gnu_get_libc_version(); + + static UserAgentInfo() + { + // 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. + driverName = TruncateOrDefault(DefaultDriverName, DriverNameMaxChars); + version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars); + var osVal = DetectOsType(); + osType = TruncateOrDefault(osVal.ToString(), OsTypeMaxChars); + osDetails = TruncateOrDefault(DetectOsDetails(osVal), OsDetailsMaxChars); + architecture = TruncateOrDefault(DetectArchitecture(), ArchMaxChars); + runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars); + + // Instantiate DTO before serializing + var dto = new UserAgentInfoDto + { + Driver = driverName, + Version = version, + OS = new UserAgentInfoDto.OsInfo + { + Type = osType, + Details = osDetails + }, + Arch = architecture, + Runtime = runtime + + }; + + // Check/Adjust payload before caching it + _cachedPayload = AdjustJsonPayloadSize(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 + private static byte[] AdjustJsonPayloadSize(UserAgentInfoDto dto) + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + + // Note: server will likely reject payloads larger than 2047 bytes + // Try if the payload fits the max allowed bytes + if (payload.Length <= JsonPayloadMaxBytesSpec) + { + return payload; + } + if (payload.Length > UserAgentPayloadMaxBytes) + { + // If the payload is over 10KB, we only send the bare minimum fields + dto.OS.Details = null; // drop OS.Details + dto.Runtime = null; // drop Runtime + dto.Arch = null; // drop Arch + payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + } + + // Last check to ensure we are within the limits(in case remaining fields are still too large) + return payload.Length > UserAgentPayloadMaxBytes + ? JsonSerializer.SerializeToUtf8Bytes(new { }, options) + : payload; + + } + + /// + /// 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 + private 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; + } + } + + /// + /// 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 + // second we fallback to OSplatform checks + 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 + 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; + } + + /// + /// Given an OsType enum, returns the edition/distro string. + /// passing the enum makes search less expensive + /// + private static string DetectOsDetails(OsType os) + { + try + { + switch (os) + { + case OsType.Windows: +#if WINDOWS + // WMI query for “Caption” + // https://learn.microsoft.com/en-us/windows/win32/wmisdk/about-wmi + using var searcher = + new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem"); + foreach (var o in searcher.Get()) + { + var caption = o["Caption"]?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(caption)) + return caption; + } +#endif + break; + + case OsType.Linux: + const string file = "/etc/os-release"; + if (File.Exists(file)) + { + foreach (var line in File.ReadAllLines(file)) + { + if (line.StartsWith("PRETTY_NAME=", StringComparison.Ordinal)) + { + var parts = line.Split('='); + if (parts.Length >= 2) + { + return parts[1].Trim().Trim('"'); + } + } + } + } + break; + + case OsType.macOS: + return "macOS " + RuntimeInformation.OSDescription; + + // FreeBSD, Android, Unknown fall through + } + + // fallback for FreeBSD, Android, Unknown or if above branches fail + var fallback = RuntimeInformation.OSDescription; + if (!string.IsNullOrWhiteSpace(fallback)) + return fallback; + } + catch + { + // swallow all exceptions + } + + return DefaultJsonValue; + } + + /// + /// Detects and reports whatever CPU architecture the guest OS exposes + /// + private static string DetectArchitecture() + { + try + { + // Returns “X86”, “X64”, “Arm”, “Arm64”, etc. + // This is the architecture of the guest process it's running in + // it does not see through to the physical host. + return RuntimeInformation.ProcessArchitecture.ToString(); + } + catch + { + // In case RuntimeInformation isn’t available or something unexpected happens + } + return DefaultJsonValue; + } + + /// + /// Reads the Microsoft.Data.SqlClient assembly’s informational version + /// or falls back to its AssemblyName.Version. + /// + private static string DetectRuntime() + { + // 1) Try the built-in .NET runtime description + try + { + string fw = RuntimeInformation.FrameworkDescription; + if (!string.IsNullOrWhiteSpace(fw)) + return fw.Trim(); + } + catch + { + // ignore and fall back + } + + // 2) On Linux, ask glibc what version it is + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + try + { + // P/Invoke into libc + nint ptr = gnu_get_libc_version(); + string glibc = Marshal.PtrToStringAnsi(ptr); + if (!string.IsNullOrWhiteSpace(glibc)) + return "glibc " + glibc.Trim(); + } + catch + { + // ignore + } + } + + // 3) If running under Mono, grab its internal display name + try + { + var mono = Type.GetType("Mono.Runtime"); + if (mono != null) + { + // Mono.Runtime.GetDisplayName() is a private static method + var mi = mono.GetMethod( + "GetDisplayName", + BindingFlags.NonPublic | BindingFlags.Static + ); + if (mi != null) + { + string name = mi.Invoke(null, null) as string; + if (!string.IsNullOrWhiteSpace(name)) + return name.Trim(); + } + } + } + catch + { + // ignore + } + + // 4) Nothing matched, give up + return DefaultJsonValue; + } + + /// + /// Retrieves a copy of the cached payload. + /// + /// A byte array containing a copy of the cached payload. The caller receives a clone of the original data to + /// ensure data integrity. + public static byte[] GetCachedPayload() + { + return (byte[])_cachedPayload.Clone(); + } + } +} + 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..8f1426fbff --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +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. + 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; } + + [JsonPropertyName(VersionJsonKey)] + public string Version { get; set; } + + [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; } + + [JsonPropertyName(DetailsJsonKey)] + public string Details { get; set; } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs index 616a8fec6f..89901cd168 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionBasicTests.cs @@ -620,5 +620,62 @@ 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); + } + } } 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..e2c60f2f0a --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.Data.SqlClient.UserAgent; +using Xunit; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Unit tests for and its companion DTO. + /// Focus areas: + /// 1. Field truncation logic + /// 2. Payload sizing and field‑dropping policy + /// 3. DTO JSON contract (key names) + /// 4. Cached payload invariants + /// + public class UserAgentInfoTests + { + // 1. Cached payload is within the 2,047‑byte spec and never null + [Fact] + public void CachedPayload_IsNotNull_And_WithinSpecLimit() + { + var field = typeof(UserAgentInfo).GetField( + name: "_cachedPayload", + bindingAttr: BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(field); + + byte[] payload = (byte[])field!.GetValue(null)!; + Assert.NotNull(payload); + Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytesSpec); + } + + // 2. 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("abcdef", 5, "abcde")] // overflow truncated + public void TruncateOrDefault_Behaviour(string? input, int max, string expected) + { + var mi = typeof(UserAgentInfo).GetMethod( + name: "TruncateOrDefault", + bindingAttr: BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(mi); + + string actual = (string)mi!.Invoke(null, new object?[] { input, max })!; + Assert.Equal(expected, actual); + } + + // 3. AdjustJsonPayloadSize drops low‑priority fields when required + [Fact] + public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() + { + // Build an inflated DTO so the raw JSON exceeds 10 KB. + string huge = new string('x', 20_000); + var dto = new UserAgentInfoDto + { + Driver = huge, + Version = huge, + OS = new UserAgentInfoDto.OsInfo + { + Type = huge, + Details = huge + }, + Arch = huge, + Runtime = huge + }; + + var mi = typeof(UserAgentInfo).GetMethod( + name: "AdjustJsonPayloadSize", + bindingAttr: BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(mi); + + byte[] payload = (byte[])mi!.Invoke(null, new object?[] { dto })!; + + // Final payload must satisfy limits + Assert.InRange(payload.Length, 1, UserAgentInfo.UserAgentPayloadMaxBytes); + + // Convert to string for field presence checks + string json = Encoding.UTF8.GetString(payload); + + // We either receive the minimal payload with only high‑priority fields, + // or we receive an empty payload in case of overflow despite dropping fields. + if (payload.Length <= 2) + { + Assert.Equal("{}", json.Trim()); + return; + } + + // High‑priority fields remain + Assert.Contains(UserAgentInfoDto.DriverJsonKey, json); + Assert.Contains(UserAgentInfoDto.VersionJsonKey, json); + Assert.Contains(UserAgentInfoDto.OsJsonKey, json); + + // Low‑priority fields removed + Assert.DoesNotContain(UserAgentInfoDto.ArchJsonKey, json); + Assert.DoesNotContain(UserAgentInfoDto.RuntimeJsonKey, json); + Assert.DoesNotContain(UserAgentInfoDto.OsInfo.DetailsJsonKey, json); + } + + // 4. DTO serializes with expected JSON property names + [Fact] + public void Dto_JsonPropertyNames_MatchConstants() + { + var dto = new UserAgentInfoDto + { + Driver = "d", + Version = "v", + OS = new UserAgentInfoDto.OsInfo { Type = "t", Details = "dd" }, + Arch = "a", + Runtime = "r" + }; + + string json = JsonSerializer.Serialize(dto); + using JsonDocument doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty(UserAgentInfoDto.DriverJsonKey, out _)); + Assert.True(root.TryGetProperty(UserAgentInfoDto.VersionJsonKey, out _)); + Assert.True(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out var osElement)); + Assert.True(osElement.TryGetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey, out _)); + Assert.True(osElement.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + Assert.True(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + Assert.True(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + } + } +} 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..8587f4b7c1 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,11 @@ public interface ITDSServerSession /// Indicates whether the client supports Vector column type /// bool IsVectorSupportEnabled { get; set; } + + /// + /// Indicates whether the client supports Vector column type + /// + 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..6c86020aaf 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 @@ -49,16 +49,32 @@ public delegate void OnAuthenticationCompletedDelegate( /// public const byte DefaultSupportedVectorFeatureExtVersion = 0x01; + /// + /// Default feature extension version supported on the server for user agent. + /// + public const byte DefaultSupportedUserAgentFeatureExtVersion = 0x0F; + /// /// Property for setting server version for vector feature extension. /// public bool EnableVectorFeatureExt { get; set; } = false; + /// + /// Property for setting server version for 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. /// @@ -287,6 +303,15 @@ public virtual TDSMessageCollection OnLogin7Request(ITDSServerSession session, T } break; } + case TDSFeatureID.UserAgentSupport: + { + if (EnableUserAgentFeatureExt) + { + // Enable User Agent Support + session.IsUserAgentSupportEnabled = true; + } + break; + } default: { @@ -654,6 +679,9 @@ protected virtual TDSMessageCollection OnAuthenticationCompleted(ITDSServerSessi } } + // Note: there can be a case here handling User Agent support, but since server + // should not actually ack this feature extension, we don't handle it here. + // 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 ///