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..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 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..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 diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs new file mode 100644 index 0000000000..d02394ef6f --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs @@ -0,0 +1,318 @@ +using System; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; + +#nullable enable + +#if WINDOWS +using System.Management; +#endif + +namespace Microsoft.Data.SqlClient.UserAgent; + +/// +/// Gathers driver + environment info, enforces size constraints, +/// and serializes into a UTF-8 JSON payload. +/// The spec document can be found at: https://microsoft.sharepoint-df.com/:w:/t/sqldevx/ERIWTt0zlCxLroNHyaPlKYwBI_LNSff6iy_wXZ8xX6nctQ?e=0hTJX7 +/// +internal static class UserAgentInfo +{ + /// + /// Maximum number of characters allowed for the 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 JsonPayloadMaxBytes = 2047; + + private const string DefaultJsonValue = "Unknown"; + private const string DriverName = "MS-MDS"; + + 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 + { + Driver = driverName, + Version = version, + OS = new UserAgentInfoDto.OsInfo + { + Type = osType, + Details = osDetails + }, + Arch = architecture, + Runtime = runtime + + }; + + } + + /// + /// 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; + } + + dto.Runtime = null; // drop Runtime + dto.Arch = null; // drop Arch + if (dto.OS != null) + { + dto.OS.Details = null; // drop OS.Details + } + + payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + if (payload.Length <= JsonPayloadMaxBytes) + { + return payload; + } + + dto.OS = null; // drop OS entirely + // Last attempt to send minimal payload driver + version only + return JsonSerializer.SerializeToUtf8Bytes(dto, options); + } + + /// + /// Truncates a string to the specified maximum length or returns a default value if input is null or empty. + /// + /// The string value to truncate + /// Maximum number of characters allowed + /// Truncated string or default value if input is invalid + internal static string TruncateOrDefault(string jsonStringVal, int maxChars) + { + try + { + if (string.IsNullOrEmpty(jsonStringVal)) + { + return DefaultJsonValue; + } + + if (jsonStringVal.Length <= maxChars) + { + return jsonStringVal; + } + + return jsonStringVal.Substring(0, maxChars); + } + catch + { + // Silently consume all exceptions + return DefaultJsonValue; + } + } + + /// + /// 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 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)) + return OsType.Linux; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return OsType.macOS; + + // Final fallback is inspecting OSDecription + // Note: This is not based on any formal specification, + // that is why we use it as a last resort. + // The string values are based on trial and error. + var desc = RuntimeInformation.OSDescription?.ToLowerInvariant() ?? ""; + if (desc.Contains("android")) + return OsType.Android; + if (desc.Contains("freebsd")) + return OsType.FreeBSD; + if (desc.Contains("windows")) + return OsType.Windows; + if (desc.Contains("linux")) + return OsType.Linux; + if (desc.Contains("darwin") || desc.Contains("mac os")) + return OsType.macOS; + } + catch + { + // swallow any unexpected errors + } + + return OsType.Unknown; + } + + /// + /// 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 new file mode 100644 index 0000000000..1dfe40d427 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UserAgent; +internal class UserAgentInfoDto +{ + // Note: JSON key names are defined as constants to avoid reflection during serialization. + // This allows us to calculate their UTF-8 encoded byte sizes efficiently without instantiating + // the DTO or relying on JsonProperty attribute resolution at runtime. The small overhead of + // maintaining constants is justified by the performance and allocation savings. + 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 + { + 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 new file mode 100644 index 0000000000..b6faf84c9a --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -0,0 +1,154 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Data.SqlClient.UserAgent; +using Xunit; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Unit tests for and its companion DTO. + /// Focus areas: + /// 1. Field truncation logic + /// 2. 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 + { + // 1. Cached payload is within the 2,047‑byte spec and never null + [Fact] + public void CachedPayload_IsNotNull_And_WithinSpecLimit() + { + byte[] payload = UserAgentInfo._cachedPayload; + Assert.NotNull(payload); + 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("abcde", 5, "abcde")] // exact max chars + [InlineData("abcdef", 5, "abcde")] // overflow truncated + public void TruncateOrDefault_Behaviour(string? input, int max, string expected) + { + string actual = UserAgentInfo.TruncateOrDefault(input!, max); + Assert.Equal(expected, actual); + } + + // 3. AdjustJsonPayloadSize drops low‑priority fields when required + [Fact] + public void AdjustJsonPayloadSize_StripsLowPriorityFields_When_PayloadTooLarge() + { + // 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 + { + Driver = huge, + Version = huge, + OS = new UserAgentInfoDto.OsInfo + { + Type = huge, + Details = huge + }, + Arch = huge, + Runtime = huge + }; + + // Capture the size before the helper mutates the DTO + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto); + Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); + + // Run the field‑dropping logic + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + Assert.NotEmpty(payload); + Assert.True(payload.Length < original.Length); // verify shrinkage + + // Structural checks using JsonDocument + using JsonDocument doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; + + // 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 _)); + + // If the "os" object survived, only its "type" sub‑field may remain. + if (root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out JsonElement os)) + { + Assert.True(os.TryGetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey, out _)); + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + } + } + + // 4. DTO JSON contract - verify names and values + [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); + 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.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()); + } + } +}