From 5fc8f705924b40fb1a4a2974a2bf018993478f22 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Wed, 16 Jul 2025 16:47:59 -0700 Subject: [PATCH 1/5] Add UserAgent Json payload --- .../Microsoft/Data/SqlClient/UserAgentInfo.cs | 387 ++++++++++++++++++ .../Data/SqlClient/UserAgentInfoDto.cs | 44 ++ .../Microsoft.Data.SqlClient.UnitTests.csproj | 3 + .../Data/SqlClient/UserAgentInfoTests.cs | 120 ++++++ 4 files changed, 554 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs create mode 100644 src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs new file mode 100644 index 0000000000..be91e47dfe --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs @@ -0,0 +1,387 @@ +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 +{ + /// + /// Gathers driver + environment info, enforces size constraints, + /// and serializes into a UTF-8 JSON payload. + /// + public static class UserAgentInfo + { + public const int DriverNameMaxChars = 16; + public const int VersionMaxChars = 16; + public const int OsTypeMaxChars = 16; + public const int OsDetailsMaxChars = 128; + public const int ArchMaxChars = 16; + public const int RuntimeMaxChars = 128; + public const int JsonPayloadMaxBytesSpec = 2047; + 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 IntPtr 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. + + 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); + } +#if DEBUG + Debug.Assert(payload.Length <= JsonPayloadMaxBytesSpec, + $"UserAgent payload is {payload.Length} bytes (spec max {JsonPayloadMaxBytesSpec})."); +#endif + return 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 + IntPtr 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; + } + + } +} + diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs new file mode 100644 index 0000000000..4a28a0d0d1 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace Microsoft.Data.SqlClient +{ + 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/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj index 2f0e12c922..6d156b2bf3 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj @@ -48,4 +48,7 @@ xunit.runner.json + + + diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs new file mode 100644 index 0000000000..4aae276d2f --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.Data.SqlClient; +using Xunit; + +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 spec limit + Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytesSpec); + + // Convert to string for field presence checks + string json = Encoding.UTF8.GetString(payload); + + // 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 _)); + } + } +} From 51478586541d1f04781e47bc7637e6ac60966d44 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Thu, 17 Jul 2025 13:28:30 -0700 Subject: [PATCH 2/5] Add unit tests and update UserAgentInfo payload checks --- .../src/Microsoft.Data.SqlClient.csproj | 2 + .../Microsoft/Data/SqlClient/UserAgentInfo.cs | 60 ++- .../Data/SqlClient/UserAgentInfoDto.cs | 2 +- .../netfx/src/Microsoft.Data.SqlClient.csproj | 2 + .../Microsoft/Data/SqlClient/UserAgentInfo.cs | 421 ++++++++++++++++++ .../Data/SqlClient/UserAgentInfoDto.cs | 45 ++ .../Data/SqlClient => }/UserAgentInfoTests.cs | 14 +- 7 files changed, 530 insertions(+), 16 deletions(-) rename src/Microsoft.Data.SqlClient/{ => netcore}/src/Microsoft/Data/SqlClient/UserAgentInfo.cs (89%) rename src/Microsoft.Data.SqlClient/{ => netcore}/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs (97%) create mode 100644 src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs create mode 100644 src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs rename src/Microsoft.Data.SqlClient/tests/UnitTests/{Microsoft/Data/SqlClient => }/UserAgentInfoTests.cs (91%) 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 01f4de3352..587bcbddc6 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -1018,6 +1018,8 @@ True Strings.resx + + Resources\Strings.resx Microsoft.Data.SqlClient.Resources.Strings.resources diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfo.cs similarity index 89% rename from src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs rename to src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfo.cs index be91e47dfe..9d5ab9a7ce 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfo.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfo.cs @@ -23,15 +23,49 @@ namespace Microsoft.Data.SqlClient /// public static class UserAgentInfo { - public const int DriverNameMaxChars = 16; - public const int VersionMaxChars = 16; - public const int OsTypeMaxChars = 16; - public const int OsDetailsMaxChars = 128; - public const int ArchMaxChars = 16; - public const int RuntimeMaxChars = 128; + /// + /// 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"; @@ -60,7 +94,7 @@ private enum OsType static UserAgentInfo() { - /// Note: We serialize 6 fields in total: + // Note: We serialize 6 fields in total: // - 4 fields with up to 16 characters each // - 2 fields with up to 128 characters each // @@ -100,7 +134,7 @@ static UserAgentInfo() // some servers may silently drop or reject such packets — behavior we may use for future probing or diagnostics. driverName = TruncateOrDefault(DefaultDriverName, DriverNameMaxChars); - version = TruncateOrDefault(ADP.GetAssemblyVersion.ToString(), VersionMaxChars); + version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars); var osVal = DetectOsType(); osType = TruncateOrDefault(osVal.ToString(), OsTypeMaxChars); osDetails = TruncateOrDefault(DetectOsDetails(osVal), OsDetailsMaxChars); @@ -157,11 +191,11 @@ private static byte[] AdjustJsonPayloadSize(UserAgentInfoDto dto) dto.Arch = null; // drop Arch payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); } -#if DEBUG - Debug.Assert(payload.Length <= JsonPayloadMaxBytesSpec, - $"UserAgent payload is {payload.Length} bytes (spec max {JsonPayloadMaxBytesSpec})."); -#endif - return payload; + + // 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; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs similarity index 97% rename from src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs rename to src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs index 4a28a0d0d1..5f8046d62e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs @@ -1,5 +1,5 @@ using System.Text.Json; - +using System.Text.Json.Serialization; namespace Microsoft.Data.SqlClient { internal class UserAgentInfoDto 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 8d178e11b6..75980c4ee0 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj @@ -936,6 +936,8 @@ True Strings.resx + + Resources\Strings.resx Microsoft.Data.SqlClient.Resources.Strings.resources diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs new file mode 100644 index 0000000000..9d5ab9a7ce --- /dev/null +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs @@ -0,0 +1,421 @@ +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 +{ + /// + /// Gathers driver + environment info, enforces size constraints, + /// and serializes into a UTF-8 JSON payload. + /// + public 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 IntPtr 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. + + 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 + IntPtr 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; + } + + } +} + diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs new file mode 100644 index 0000000000..5b139c7f3a --- /dev/null +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Data.SqlClient +{ + 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/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs similarity index 91% rename from src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs rename to src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs index 4aae276d2f..c49c83239d 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UserAgentInfoTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -5,6 +5,8 @@ using Microsoft.Data.SqlClient; using Xunit; +#nullable enable + namespace Microsoft.Data.SqlClient.UnitTests { /// @@ -74,12 +76,20 @@ public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() byte[] payload = (byte[])mi!.Invoke(null, new object?[] { dto })!; - // Final payload must satisfy spec limit - Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytesSpec); + // 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); From 7b2ad0ef01cf9baa8f342599569e7f40caa148cc Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Fri, 18 Jul 2025 14:07:14 -0700 Subject: [PATCH 3/5] PR review changes 1 --- .../src/Microsoft.Data.SqlClient.csproj | 8 +- .../netfx/src/Microsoft.Data.SqlClient.csproj | 8 +- .../Microsoft/Data/SqlClient/UserAgentInfo.cs | 421 ------------------ .../Data/SqlClient/UserAgentInfoDto.cs | 45 -- .../SqlClient/UserAgent}/UserAgentInfo.cs | 12 +- .../SqlClient/UserAgent}/UserAgentInfoDto.cs | 2 +- .../Microsoft.Data.SqlClient.UnitTests.csproj | 3 - .../tests/UnitTests/UserAgentInfoTests.cs | 2 +- 8 files changed, 20 insertions(+), 481 deletions(-) delete mode 100644 src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs delete mode 100644 src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs rename src/Microsoft.Data.SqlClient/{netcore/src/Microsoft/Data/SqlClient => src/Microsoft/Data/SqlClient/UserAgent}/UserAgentInfo.cs (98%) rename src/Microsoft.Data.SqlClient/{netcore/src/Microsoft/Data/SqlClient => src/Microsoft/Data/SqlClient/UserAgent}/UserAgentInfoDto.cs (97%) 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 587bcbddc6..90899f35fc 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -792,6 +792,12 @@ Microsoft\Data\SqlTypes\SqlVector.cs + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs + + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs + Resources\ResCategoryAttribute.cs @@ -1018,8 +1024,6 @@ True Strings.resx - - Resources\Strings.resx Microsoft.Data.SqlClient.Resources.Strings.resources 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 75980c4ee0..fbfcbe6e44 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj @@ -903,6 +903,12 @@ Microsoft\Data\SqlTypes\SqlVector.cs + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs + + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs + Resources\ResDescriptionAttribute.cs @@ -936,8 +942,6 @@ True Strings.resx - - Resources\Strings.resx Microsoft.Data.SqlClient.Resources.Strings.resources diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs deleted file mode 100644 index 9d5ab9a7ce..0000000000 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfo.cs +++ /dev/null @@ -1,421 +0,0 @@ -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 -{ - /// - /// Gathers driver + environment info, enforces size constraints, - /// and serializes into a UTF-8 JSON payload. - /// - public 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 IntPtr 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. - - 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 - IntPtr 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; - } - - } -} - diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs deleted file mode 100644 index 5b139c7f3a..0000000000 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.Data.SqlClient -{ - 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/netcore/src/Microsoft/Data/SqlClient/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs similarity index 98% rename from src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfo.cs rename to src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs index 9d5ab9a7ce..cca0e13894 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfo.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs @@ -15,13 +15,13 @@ using System.Management; #endif -namespace Microsoft.Data.SqlClient +namespace Microsoft.Data.SqlClient.UserAgent { /// /// Gathers driver + environment info, enforces size constraints, /// and serializes into a UTF-8 JSON payload. /// - public static class UserAgentInfo + internal static class UserAgentInfo { /// /// Maximum number of characters allowed for the driver name. @@ -90,7 +90,7 @@ private enum OsType // P/Invoke signature for glibc detection [DllImport("libc", EntryPoint = "gnu_get_libc_version", CallingConvention = CallingConvention.Cdecl)] - private static extern IntPtr gnu_get_libc_version(); + private static extern nint gnu_get_libc_version(); static UserAgentInfo() { @@ -132,7 +132,7 @@ static UserAgentInfo() // '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(); @@ -152,7 +152,7 @@ static UserAgentInfo() Details = osDetails }, Arch = architecture, - Runtime = runtime, + Runtime = runtime }; @@ -377,7 +377,7 @@ private static string DetectRuntime() try { // P/Invoke into libc - IntPtr ptr = gnu_get_libc_version(); + nint ptr = gnu_get_libc_version(); string glibc = Marshal.PtrToStringAnsi(ptr); if (!string.IsNullOrWhiteSpace(glibc)) return "glibc " + glibc.Trim(); diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs similarity index 97% rename from src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs rename to src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs index 5f8046d62e..8f1426fbff 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/UserAgentInfoDto.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Microsoft.Data.SqlClient +namespace Microsoft.Data.SqlClient.UserAgent { internal class UserAgentInfoDto { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj index 6d156b2bf3..2f0e12c922 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj @@ -48,7 +48,4 @@ xunit.runner.json - - - diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs index c49c83239d..e2c60f2f0a 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -2,7 +2,7 @@ using System.Reflection; using System.Text; using System.Text.Json; -using Microsoft.Data.SqlClient; +using Microsoft.Data.SqlClient.UserAgent; using Xunit; #nullable enable From 56a4c6c3b185f3ae82f419d863309d6695423e63 Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Thu, 24 Jul 2025 17:32:42 -0700 Subject: [PATCH 4/5] Review updates for Detect functions and tests --- .../Data/SqlClient/UserAgent/UserAgentInfo.cs | 643 ++++++++---------- .../SqlClient/UserAgent/UserAgentInfoDto.cs | 79 +-- .../tests/UnitTests/UserAgentInfoTests.cs | 136 ++-- 3 files changed, 391 insertions(+), 467 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 cca0e13894..d02394ef6f 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 @@ -1,421 +1,318 @@ 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; +#nullable enable + #if WINDOWS using System.Management; #endif -namespace Microsoft.Data.SqlClient.UserAgent +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 { /// - /// Gathers driver + environment info, enforces size constraints, - /// and serializes into a UTF-8 JSON payload. + /// Maximum number of characters allowed for the driver name. /// - 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 - } + private const int DriverNameMaxChars = 16; - // P/Invoke signature for glibc detection - [DllImport("libc", EntryPoint = "gnu_get_libc_version", CallingConvention = CallingConvention.Cdecl)] - private static extern nint gnu_get_libc_version(); + /// + /// Maximum number of characters allowed for the driver version. + /// + private const int VersionMaxChars = 16; - 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); - } + /// + /// Maximum number of characters allowed for the operating system type. + /// + private const int OsTypeMaxChars = 16; - /// - /// 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); - } + /// + /// 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; - // 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; + /// + /// 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 JsonPayloadMaxBytes = 2047; + + private const string DefaultJsonValue = "Unknown"; + private const string DriverName = "MS-MDS"; - /// - /// 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) + private static readonly UserAgentInfoDto _dto; + public static readonly byte[] _cachedPayload; + + private enum OsType + { + Windows, + Linux, + macOS, + FreeBSD, + Android, + Unknown + } + + static UserAgentInfo() + { + _dto = BuildDto(); + _cachedPayload = AdjustJsonPayloadSize(_dto); + } + + static UserAgentInfoDto BuildDto() + { + // 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 driverName = TruncateOrDefault(DriverName, DriverNameMaxChars); + var version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars); + var osType = TruncateOrDefault(DetectOsType().ToString(), OsTypeMaxChars); + var osDetails = TruncateOrDefault(DetectOsDetails(), OsDetailsMaxChars); + var architecture = TruncateOrDefault(DetectArchitecture(), ArchMaxChars); + var runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars); + + // Instantiate DTO before serializing + return new UserAgentInfoDto { - try - { - if (string.IsNullOrEmpty(jsonStringVal)) - { - return DefaultJsonValue; - } + Driver = driverName, + Version = version, + OS = new UserAgentInfoDto.OsInfo + { + Type = osType, + Details = osDetails + }, + Arch = architecture, + Runtime = runtime - if (jsonStringVal.Length <= maxChars) - { - return jsonStringVal; - } + }; - return jsonStringVal.Substring(0, maxChars); - } - catch - { - // Silently consume all exceptions - return DefaultJsonValue; - } + } + + /// + /// 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) + { + 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; } - /// - /// Detects the OS platform and returns the matching OsType enum. - /// - private static OsType DetectOsType() + dto.Runtime = null; // drop Runtime + dto.Arch = null; // drop Arch + if (dto.OS != null) { - 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 - } + dto.OS.Details = null; // drop OS.Details + } - return OsType.Unknown; + 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 + return JsonSerializer.SerializeToUtf8Bytes(dto, options); + } - /// - /// Given an OsType enum, returns the edition/distro string. - /// passing the enum makes search less expensive - /// - private static string DetectOsDetails(OsType os) + /// + /// 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 { - try + if (string.IsNullOrEmpty(jsonStringVal)) { - 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; + return DefaultJsonValue; } - catch + + if (jsonStringVal.Length <= maxChars) { - // swallow all exceptions + return jsonStringVal; } - return DefaultJsonValue; + return jsonStringVal.Substring(0, maxChars); } - - /// - /// Detects and reports whatever CPU architecture the guest OS exposes - /// - private static string DetectArchitecture() + catch { - 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 - } + // Silently consume all exceptions return DefaultJsonValue; } + } - /// - /// Reads the Microsoft.Data.SqlClient assembly’s informational version - /// or falls back to its AssemblyName.Version. - /// - private static string DetectRuntime() + /// + /// Detects the OS platform and returns the matching OsType enum. + /// + private static OsType DetectOsType() + { + try { - // 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 + // 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 NETCOREAPP3_0_OR_GREATER || NET5_0_OR_GREATER + if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) + return OsType.FreeBSD; +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD"))) + return OsType.FreeBSD; +#endif + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return OsType.Windows; 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 - } - } + 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 + } - // 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 - } + return OsType.Unknown; + } - // 4) Nothing matched, give up - 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 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; + } + + /// + /// 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(); } } 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 index 8f1426fbff..1dfe40d427 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -1,44 +1,47 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Microsoft.Data.SqlClient.UserAgent + +#nullable enable + +namespace Microsoft.Data.SqlClient.UserAgent; +internal class UserAgentInfoDto { - 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"; + + + // TODO: Does this need to be nullable? + [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 { - // 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; } - } + 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/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs index e2c60f2f0a..b6faf84c9a 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -1,6 +1,4 @@ -using System; -using System.Reflection; -using System.Text; +using System.Text; using System.Text.Json; using Microsoft.Data.SqlClient.UserAgent; using Xunit; @@ -13,9 +11,10 @@ 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 + /// 2. Truncation verification + /// 3. Payload size adjustment and field dropping + /// 4. DTO JSON contract (key names and values) + /// 5. Combined truncation, adjustment, and serialization /// public class UserAgentInfoTests { @@ -23,30 +22,21 @@ public class UserAgentInfoTests [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)!; + byte[] payload = UserAgentInfo._cachedPayload; Assert.NotNull(payload); - Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytesSpec); + Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytes); } // 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 + [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) { - 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 })!; + string actual = UserAgentInfo.TruncateOrDefault(input!, max); Assert.Equal(expected, actual); } @@ -54,7 +44,8 @@ public void TruncateOrDefault_Behaviour(string? input, int max, string expected) [Fact] public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() { - // Build an inflated DTO so the raw JSON exceeds 10 KB. + // Build an inflated DTO so AdjustJsonPayloadSize + // must fall back to its “drop fields” logic. string huge = new string('x', 20_000); var dto = new UserAgentInfoDto { @@ -69,39 +60,36 @@ public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() Runtime = huge }; - var mi = typeof(UserAgentInfo).GetMethod( - name: "AdjustJsonPayloadSize", - bindingAttr: BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(mi); + // Capture the size before the helper mutates the DTO + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto); + Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); - byte[] payload = (byte[])mi!.Invoke(null, new object?[] { dto })!; + // Run the field‑dropping logic + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + Assert.NotEmpty(payload); + Assert.True(payload.Length < original.Length); // verify shrinkage - // Final payload must satisfy limits - Assert.InRange(payload.Length, 1, UserAgentInfo.UserAgentPayloadMaxBytes); + // Structural checks using JsonDocument + using JsonDocument doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; - // Convert to string for field presence checks - string json = Encoding.UTF8.GetString(payload); + // High‑priority fields must survive. + Assert.True(root.TryGetProperty(UserAgentInfoDto.DriverJsonKey, out _)); + Assert.True(root.TryGetProperty(UserAgentInfoDto.VersionJsonKey, out _)); + + // Low‑priority fields must be gone. + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); - // 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) + // If the "os" object survived, only its "type" sub‑field may remain. + if (root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out JsonElement os)) { - Assert.Equal("{}", json.Trim()); - return; + Assert.True(os.TryGetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey, out _)); + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); } - - // 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 + // 4. DTO JSON contract - verify names and values [Fact] public void Dto_JsonPropertyNames_MatchConstants() { @@ -115,16 +103,52 @@ public void Dto_JsonPropertyNames_MatchConstants() }; string json = JsonSerializer.Serialize(dto); + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + // Assert – root‑level fields + Assert.Equal("d", root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal("v", root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + Assert.Equal("a", root.GetProperty(UserAgentInfoDto.ArchJsonKey).GetString()); + Assert.Equal("r", root.GetProperty(UserAgentInfoDto.RuntimeJsonKey).GetString()); + + // Assert – nested os object + JsonElement os = root.GetProperty(UserAgentInfoDto.OsJsonKey); + Assert.Equal("t", os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); + Assert.Equal("dd", os.GetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey).GetString()); + } + + // 5. 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.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 _)); + 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 ede42a1ad81e9f64ab27fa9f5a360796ac0864cd Mon Sep 17 00:00:00 2001 From: Saransh Sharma Date: Mon, 28 Jul 2025 11:53:16 -0700 Subject: [PATCH 5/5] Adding more tests --- .../Data/SqlClient/UserAgent/UserAgentInfo.cs | 76 ++++++++----- .../SqlClient/UserAgent/UserAgentInfoDto.cs | 7 +- .../tests/UnitTests/UserAgentInfoTests.cs | 107 ++++++++++++++---- 3 files changed, 137 insertions(+), 53 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 d02394ef6f..f690ffc761 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 @@ -6,10 +6,6 @@ #nullable enable -#if WINDOWS -using System.Management; -#endif - namespace Microsoft.Data.SqlClient.UserAgent; /// @@ -53,10 +49,10 @@ internal static class UserAgentInfo /// Maximum number of bytes allowed for the user agent json payload. /// Payloads larger than this may be rejected by the server. /// - public const int JsonPayloadMaxBytes = 2047; + internal const int JsonPayloadMaxBytes = 2047; private const string DefaultJsonValue = "Unknown"; - private const string DriverName = "MS-MDS"; + internal const string DriverName = "MS-MDS"; private static readonly UserAgentInfoDto _dto; public static readonly byte[] _cachedPayload; @@ -77,7 +73,7 @@ static UserAgentInfo() _cachedPayload = AdjustJsonPayloadSize(_dto); } - static UserAgentInfoDto BuildDto() + internal static UserAgentInfoDto BuildDto() { // Note: We serialize 6 fields in total: // - 4 fields with up to 16 characters each @@ -118,26 +114,19 @@ static UserAgentInfoDto BuildDto() // - 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 driverName = TruncateOrDefault(DriverName, DriverNameMaxChars); - var version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars); - var osType = TruncateOrDefault(DetectOsType().ToString(), OsTypeMaxChars); - var osDetails = TruncateOrDefault(DetectOsDetails(), OsDetailsMaxChars); - var architecture = TruncateOrDefault(DetectArchitecture(), ArchMaxChars); - var runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars); // Instantiate DTO before serializing return new UserAgentInfoDto { - Driver = driverName, - Version = version, + Driver = TruncateOrDefault(DriverName, DriverNameMaxChars), + Version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars), OS = new UserAgentInfoDto.OsInfo { - Type = osType, - Details = osDetails + Type = TruncateOrDefault(DetectOsType().ToString(), OsTypeMaxChars), + Details = TruncateOrDefault(DetectOsDetails(), OsDetailsMaxChars) }, - Arch = architecture, - Runtime = runtime - + Arch = TruncateOrDefault(DetectArchitecture(), ArchMaxChars), + Runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars) }; } @@ -224,26 +213,51 @@ private static OsType DetectOsType() { // 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; + 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 NETCOREAPP3_0_OR_GREATER || NET5_0_OR_GREATER - if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) + +#if NET462 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD"))) + { return OsType.FreeBSD; + } #else - if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD"))) + 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, @@ -297,8 +311,8 @@ private static string DetectArchitecture() catch { // In case RuntimeInformation isn’t available or something unexpected happens + return DefaultJsonValue; } - return DefaultJsonValue; } /// @@ -309,7 +323,9 @@ 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(); 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 index 1dfe40d427..9e1a0cf911 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Data.Common; #nullable enable @@ -16,13 +17,11 @@ internal class UserAgentInfoDto public const string ArchJsonKey = "arch"; public const string RuntimeJsonKey = "runtime"; - - // TODO: Does this need to be nullable? [JsonPropertyName(DriverJsonKey)] - public string? Driver { get; set; } + public string Driver { get; set; } = UserAgentInfo.DriverName; [JsonPropertyName(VersionJsonKey)] - public string? Version { get; set; } + public string Version { get; set; } = ADP.GetAssemblyVersion().ToString(); [JsonPropertyName(OsJsonKey)] public OsInfo? OS { get; set; } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs index b6faf84c9a..be7f02f2d8 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Data.SqlClient.UserAgent; using Xunit; @@ -60,8 +61,15 @@ public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() Runtime = huge }; + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + // Capture the size before the helper mutates the DTO - byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto); + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto, options); Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); // Run the field‑dropping logic @@ -89,33 +97,94 @@ public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() } } - // 4. DTO JSON contract - verify names and values - [Fact] - public void Dto_JsonPropertyNames_MatchConstants() + // 4. DTO JSON contract - verify names and values(parameterized) + [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)] // all optional fields null + public void Dto_JsonPropertyNames_MatchConstants( + string driver, + string version, + string? osType, + string? osDetails, + string? arch, + string? runtime) { var dto = new UserAgentInfoDto { - Driver = "d", - Version = "v", - OS = new UserAgentInfoDto.OsInfo { Type = "t", Details = "dd" }, - Arch = "a", - Runtime = "r" + Driver = driver, + Version = version, + OS = new UserAgentInfoDto.OsInfo + { + Type = osType, + Details = string.IsNullOrEmpty(osDetails) ? null : osDetails + }, + Arch = string.IsNullOrEmpty(arch) ? null : arch, + Runtime = string.IsNullOrEmpty(runtime) ? null : runtime + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false }; - string json = JsonSerializer.Serialize(dto); - using JsonDocument doc = JsonDocument.Parse(json); + string json = JsonSerializer.Serialize(dto, options); + JsonDocument doc = JsonDocument.Parse(json); JsonElement root = doc.RootElement; - // Assert – root‑level fields - Assert.Equal("d", root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); - Assert.Equal("v", root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); - Assert.Equal("a", root.GetProperty(UserAgentInfoDto.ArchJsonKey).GetString()); - Assert.Equal("r", root.GetProperty(UserAgentInfoDto.RuntimeJsonKey).GetString()); + // always expected + Assert.Equal(driver, + root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(version, + root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); - // Assert – nested os object + // optional Arch + if (dto.Arch is null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + } + else + { + Assert.Equal(dto.Arch, + root.GetProperty(UserAgentInfoDto.ArchJsonKey).GetString()); + } + + // optional Runtime + if (dto.Runtime is null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + } + else + { + Assert.Equal(dto.Runtime, + root.GetProperty(UserAgentInfoDto.RuntimeJsonKey).GetString()); + } + + // nested OS object JsonElement os = root.GetProperty(UserAgentInfoDto.OsJsonKey); - Assert.Equal("t", os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); - Assert.Equal("dd", os.GetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey).GetString()); + + if (dto.OS!.Type is null) + { + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey, out _)); + } + else + { + Assert.Equal(dto.OS.Type, + os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); + } + + if (dto.OS!.Details is null) + { + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + } + else + { + Assert.Equal(dto.OS.Details, + os.GetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey).GetString()); + } } // 5. End-to-end test that combines truncation, adjustment, and serialization