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
///