diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj index 97f81d7c4b..c3b06897c1 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -789,6 +789,12 @@ Microsoft\Data\SqlTypes\SqlVector.cs + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs + + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs + Resources\ResCategoryAttribute.cs diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj index 84ce735592..87e865c4ed 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj @@ -900,6 +900,12 @@ Microsoft\Data\SqlTypes\SqlVector.cs + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs + + + Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs + Resources\ResDescriptionAttribute.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs new file mode 100644 index 0000000000..d927155dbb --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs @@ -0,0 +1,361 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UserAgent; + +/// +/// Gathers driver + environment info, enforces size constraints, +/// and serializes into a UTF-8 JSON payload. +/// The spec document can be found at: https://microsoft.sharepoint-df.com/:w:/t/sqldevx/ERIWTt0zlCxLroNHyaPlKYwBI_LNSff6iy_wXZ8xX6nctQ?e=0hTJX7 +/// +internal static class UserAgentInfo +{ + /// + /// Maximum number of characters allowed for the system architecture. + /// + private const int ArchMaxChars = 16; + + /// + /// Maximum number of characters allowed for the driver name. + /// + internal const int DriverNameMaxChars = 16; + + /// + /// Maximum number of bytes allowed for the user agent json payload. + /// Payloads larger than this may be rejected by the server. + /// + internal const int JsonPayloadMaxBytes = 2047; + + /// + /// Maximum number of characters allowed for the operating system details. + /// + private const int OsDetailsMaxChars = 128; + + /// + /// Maximum number of characters allowed for the operating system type. + /// + internal const int OsTypeMaxChars = 16; + + /// + /// Maximum number of characters allowed for the driver runtime. + /// + private const int RuntimeMaxChars = 128; + + /// + /// Maximum number of characters allowed for the driver version. + /// + internal const int VersionMaxChars = 16; + + + internal const string DefaultJsonValue = "Unknown"; + internal const string DriverName = "MS-MDS"; + + private static readonly UserAgentInfoDto s_dto; + private static readonly byte[] s_userAgentCachedPayload; + + /// + /// Provides the UTF-8 encoded UserAgent JSON payload as a cached read-only memory buffer. + /// The value is computed once during process initialization and reused across all calls. + /// No re-encoding or recalculation occurs at access time, and the same memory is safely shared across all threads. + /// + public static ReadOnlyMemory UserAgentCachedJsonPayload => s_userAgentCachedPayload; + + private enum OsType + { + Windows, + Linux, + macOS, + FreeBSD, + Android, + Unknown + } + + static UserAgentInfo() + { + s_dto = BuildDto(); + s_userAgentCachedPayload = AdjustJsonPayloadSize(s_dto); + } + + /// + /// This function returns the appropriately sized json payload + /// We check the size of encoded json payload, if it is within limits we return the dto to be cached + /// other wise we drop some fields to reduce the size of the payload. + /// + /// Data Transfer Object for the json payload + /// Serialized UTF-8 encoded json payload version of DTO within size limit + internal static byte[] AdjustJsonPayloadSize(UserAgentInfoDto dto) + { + // Note: We serialize 6 fields in total: + // - 4 fields with up to 16 characters each + // - 2 fields with up to 128 characters each + // + // For estimating **on-the-wire UTF-8 size** of the serialized JSON: + // 1) For the 4 fields of 16 characters: + // - In worst case (all characters require escaping in JSON, e.g., quotes, backslashes, control chars), + // each character may expand to 2–6 bytes in the JSON string (e.g., \" = 2 bytes, \uXXXX = 6 bytes) + // - Assuming full escape with \uXXXX form (6 bytes per char): 4 × 16 × 6 = 384 bytes (extreme worst case) + // - For unescaped high-plane Unicode (e.g., emojis), UTF-8 uses up to 4 bytes per character: + // 4 × 16 × 4 = 256 bytes (UTF-8 max) + // + // Conservative max estimate for these fields = **384 bytes** + // + // 2) For the 2 fields of 128 characters: + // - Worst-case with \uXXXX escape sequences: 2 × 128 × 6 = 1,536 bytes + // - Worst-case with high Unicode: 2 × 128 × 4 = 1,024 bytes + // + // Conservative max estimate for these fields = **1,536 bytes** + // + // Combined worst-case for value content = 384 + 1536 = **1,920 bytes** + // + // 3) The rest of the serialized JSON payload (object braces, field names, quotes, colons, commas) is fixed. + // Based on measurements, it typically adds to about **81 bytes**. + // + // Final worst-case estimate for total payload on the wire (UTF-8 encoded): + // 1,920 + 81 = **2,001 bytes** + // + // This is still below our spec limit of 2,047 bytes. + // + // TDS Prelogin7 packets support up to 65,535 bytes (including headers), but many server versions impose + // stricter limits for prelogin payloads. + // + // As a safety measure: + // - If the serialized payload exceeds **10 KB**, we fallback to transmitting only essential fields: + // 'driver', 'version', and 'os.type' + // - If the payload exceeds 2,047 bytes but remains within sensible limits, we still send it, but note that + // some servers may silently drop or reject such packets — behavior we may use for future probing or diagnostics. + // - If payload exceeds 10KB even after dropping fields , we send an empty payload. + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + + // We try to send the payload if it is within the limits. + // Otherwise we drop some fields to reduce the size of the payload and try one last time + // Note: server will reject payloads larger than 2047 bytes + // Try if the payload fits the max allowed bytes + if (payload.Length <= JsonPayloadMaxBytes) + { + return payload; + } + + dto.Runtime = null; // drop Runtime + dto.Arch = null; // drop Arch + if (dto.OS != null) + { + dto.OS.Details = null; // drop OS.Details + } + + payload = JsonSerializer.SerializeToUtf8Bytes(dto, options); + if (payload.Length <= JsonPayloadMaxBytes) + { + return payload; + } + + dto.OS = null; // drop OS entirely + // Last attempt to send minimal payload driver + version only + // As per the comment in AdjustJsonPayloadSize, we know driver + version cannot be larger than the max + return JsonSerializer.SerializeToUtf8Bytes(dto, options); + } + + internal static UserAgentInfoDto BuildDto() + { + // Instantiate DTO before serializing + return new UserAgentInfoDto + { + Driver = TruncateOrDefault(DriverName, DriverNameMaxChars), + Version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars), + OS = new UserAgentInfoDto.OsInfo + { + Type = TruncateOrDefault(DetectOsType().ToString(), OsTypeMaxChars), + Details = TruncateOrDefault(DetectOsDetails(), OsDetailsMaxChars) + }, + Arch = TruncateOrDefault(DetectArchitecture(), ArchMaxChars), + Runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars) + }; + + } + + /// + /// Detects and reports whatever CPU architecture the guest OS exposes + /// + private static string DetectArchitecture() + { + try + { + // Returns the architecture of the current process (e.g., "X86", "X64", "Arm", "Arm64"). + // Note: This reflects the architecture of the running process, not the physical host system. + return RuntimeInformation.ProcessArchitecture.ToString(); + } + catch + { + // In case RuntimeInformation isn’t available or something unexpected happens + return DefaultJsonValue; + } + } + + /// + /// Retrieves the operating system details based on RuntimeInformation. + /// + private static string DetectOsDetails() + { + var osDetails = RuntimeInformation.OSDescription; + if (!string.IsNullOrWhiteSpace(osDetails)) + { + return osDetails; + } + + return DefaultJsonValue; + } + + /// + /// Detects the OS platform and returns the matching OsType enum. + /// + private static OsType DetectOsType() + { + try + { + // first we try with built-in checks (Android and FreeBSD also report Linux so they are checked first) +#if NET6_0_OR_GREATER + if (OperatingSystem.IsAndroid()) + { + return OsType.Android; + } + if (OperatingSystem.IsFreeBSD()) + { + return OsType.FreeBSD; + } + if (OperatingSystem.IsWindows()) + { + return OsType.Windows; + } + if (OperatingSystem.IsLinux()) + { + return OsType.Linux; + } + if (OperatingSystem.IsMacOS()) + { + return OsType.macOS; + } +#endif + +#if NET462 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD"))) + { + return OsType.FreeBSD; + } +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) + { + return OsType.FreeBSD; + } +#endif + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OsType.Windows; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OsType.Linux; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OsType.macOS; + } + + // Final fallback is inspecting OSDecription + // Note: This is not based on any formal specification, + // that is why we use it as a last resort. + // The string values are based on trial and error. + var desc = RuntimeInformation.OSDescription?.ToLowerInvariant() ?? ""; + if (desc.Contains("android")) + { + return OsType.Android; + } + if (desc.Contains("freebsd")) + { + return OsType.FreeBSD; + } + if (desc.Contains("windows")) + { + return OsType.Windows; + } + if (desc.Contains("linux")) + { + return OsType.Linux; + } + if (desc.Contains("darwin") || desc.Contains("mac os")) + { + return OsType.macOS; + } + } + catch + { + // swallow any unexpected errors + return OsType.Unknown; + } + return OsType.Unknown; + } + + /// + /// Returns the framework description as a string. + /// + private static string DetectRuntime() + { + // FrameworkDescription is never null, but IsNullOrWhiteSpace covers it anyway + var desc = RuntimeInformation.FrameworkDescription; + if (string.IsNullOrWhiteSpace(desc)) + { + return DefaultJsonValue; + } + + // at this point, desc is non‑null, non‑empty (after trimming) + return desc.Trim(); + } + + /// + /// Truncates a string to the specified maximum length or returns a default value if input is null or empty. + /// + /// The string value to truncate + /// Maximum number of characters allowed + /// Truncated string or default value if input is invalid + internal static string TruncateOrDefault(string? jsonStringVal, int maxChars) + { + try + { + if (string.IsNullOrEmpty(jsonStringVal)) + { + return DefaultJsonValue; + } + + if (maxChars <= 0) + { + return DefaultJsonValue; + } + + if (jsonStringVal!.Length <= maxChars) + { + return jsonStringVal; + } + + return jsonStringVal.Substring(0, maxChars); + } + catch + { + // Silently consume all exceptions + return DefaultJsonValue; + } + } + +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs new file mode 100644 index 0000000000..2c61d1c4bb --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UserAgent; +internal class UserAgentInfoDto +{ + // Note: JSON key names are defined as constants to avoid reflection during serialization. + // This allows us to calculate their UTF-8 encoded byte sizes efficiently without instantiating + // the DTO or relying on JsonProperty attribute resolution at runtime. The small overhead of + // maintaining constants is justified by the performance and allocation savings. + + // Note: These values reflect the order of the JSON fields defined in the spec. + // The order is maintained to match the JSON payload structure. + public const string DriverJsonKey = "driver"; + public const string VersionJsonKey = "version"; + public const string OsJsonKey = "os"; + public const string ArchJsonKey = "arch"; + public const string RuntimeJsonKey = "runtime"; + + [JsonPropertyName(DriverJsonKey)] + public string Driver { get; set; } = string.Empty; + + [JsonPropertyName(VersionJsonKey)] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName(OsJsonKey)] + public OsInfo? OS { get; set; } + + [JsonPropertyName(ArchJsonKey)] + public string? Arch { get; set; } + + [JsonPropertyName(RuntimeJsonKey)] + public string? Runtime { get; set; } + + public class OsInfo + { + public const string TypeJsonKey = "type"; + public const string DetailsJsonKey = "details"; + + [JsonPropertyName(TypeJsonKey)] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName(DetailsJsonKey)] + public string? Details { get; set; } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs new file mode 100644 index 0000000000..d45c188c43 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs @@ -0,0 +1,331 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Common; +using Microsoft.Data.SqlClient.UserAgent; +using Xunit; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Unit tests for and its companion DTO. + /// Focus areas: + /// 1. Cached payload size and non-nullability + /// 2. Default expected value check for payload fields + /// 3. Payload size adjustment and field dropping(all low priority fields) + /// 4. Payload size adjustment and field dropping(drop particular low priority fields: arch, runtime and os.description) + /// 5. DTO JSON contract (key names and values) + /// 6. Combined truncation, adjustment, and serialization + /// + public class UserAgentInfoTests + { + // Cached payload is within the 2,047‑byte spec and never null + [Fact] + public void CachedPayload_IsNotNull_And_WithinSpecLimit() + { + ReadOnlyMemory payload = UserAgentInfo.UserAgentCachedJsonPayload; + Assert.False(payload.IsEmpty); + Assert.InRange(payload.Length, 1, UserAgentInfo.JsonPayloadMaxBytes); + } + + // Cached payload contains the expected values for driver name and version + [Fact] + public void CachedPayload_Contains_Correct_DriverName_And_Version() + { + // Arrange: retrieve the raw JSON payload bytes and determine what we expect + ReadOnlyMemory payload = UserAgentInfo.UserAgentCachedJsonPayload; + Assert.False(payload.IsEmpty); // guard against empty payload + + // compute the expected driver and version + string expectedDriver = UserAgentInfo.DriverName; + string expectedVersion = ADP.GetAssemblyVersion().ToString(); + + // Act: turn the bytes back into JSON and pull out the fields + using JsonDocument document = JsonDocument.Parse(payload); + JsonElement root = document.RootElement; + string actualDriver = root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()!; + string actualVersion = root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()!; + + // Assert: the driver and version in the payload match the expected values + Assert.Equal(expectedDriver, actualDriver); + Assert.Equal(expectedVersion, actualVersion); + } + + // TruncateOrDefault respects null, empty, fit, and overflow cases + [Theory] + [InlineData(null, 5, "Unknown")] // null returns default + [InlineData("", 5, "Unknown")] // empty returns default + [InlineData("abc", 5, "abc")] // within limit unchanged + [InlineData("abcde", 5, "abcde")] // exact max chars + [InlineData("abcdef", 5, "abcde")] // overflow truncated + public void TruncateOrDefault_Behaviour(string? input, int max, string expected) + { + string actual = UserAgentInfo.TruncateOrDefault(input, max); + Assert.Equal(expected, actual); + } + + // AdjustJsonPayloadSize drops all low‑priority fields when required + + /// + /// Verifies that AdjustJsonPayloadSize truncates the DTO’s JSON when it exceeds the maximum size. + /// High-priority fields (Driver, Version) must remain, low-priority fields (Arch, Runtime and OS) are removed + /// + [Fact] + public void AdjustJsonPayloadSize_DropAllLowPriorityFields_When_PayloadTooLarge() + { + // Arrange: create a DTO whose serialized JSON is guaranteed to exceed the max size + string huge = new string('x', 20_000); + var dto = new UserAgentInfoDto + { + Driver = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars), + Version = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars), + OS = new UserAgentInfoDto.OsInfo + { + Type = huge, + Details = huge + }, + Arch = huge, + Runtime = huge + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + string expectedDriverName = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars); + string expectedVersion = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars); + + // Capture the size before the helper mutates the DTO + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto, options); + Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); + + // Act: apply the size-adjustment helper + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + + // Assert: payload is smaller and not empty + Assert.NotEmpty(payload); + Assert.True(payload.Length < original.Length); + + // Structural checks using JsonDocument + using JsonDocument doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; + + // High-priority fields must still be present(driver name and version) + Assert.Equal(expectedDriverName, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(expectedVersion, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + // Low-priority fields should have been removed(arch and runtime) + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + + // OS block should have been removed entirely + Assert.False(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out _)); + } + + /// + /// Verifies that AdjustJsonPayloadSize truncates the DTO’s JSON when it exceeds the maximum size. + /// High-priority fields (Driver, Version) must remain, low-priority fields (Arch, Runtime and OS.details) are removed + /// Note that OS subfield(Type) is preserved, but OS.Details is dropped. + /// + [Fact] + public void AdjustJsonPayloadSize_DropSpecificPriorityFields_Excluding_OsType_When_PayloadTooLarge() + { + // Arrange: create a DTO whose serialized JSON is guaranteed to exceed the max size + string huge = new string('x', 20_000); + var dto = new UserAgentInfoDto + { + Driver = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars), + Version = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars), + OS = new UserAgentInfoDto.OsInfo + { + Type = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.OsTypeMaxChars), + Details = huge + }, + Arch = huge, + Runtime = huge + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + string expectedDriverName = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.DriverNameMaxChars); + string expectedVersion = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.VersionMaxChars); + string expectedOsType = UserAgentInfo.TruncateOrDefault(huge, UserAgentInfo.OsTypeMaxChars); + + // Capture the size before the helper mutates the DTO + byte[] original = JsonSerializer.SerializeToUtf8Bytes(dto, options); + Assert.True(original.Length > UserAgentInfo.JsonPayloadMaxBytes); + + // Act: apply the size-adjustment helper + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + + // Assert: payload is smaller and not empty + Assert.NotEmpty(payload); + Assert.True(payload.Length < original.Length); + + // Structural checks using JsonDocument + using JsonDocument doc = JsonDocument.Parse(payload); + JsonElement root = doc.RootElement; + + // High-priority fields must still be present(driver name and version) and truncated to expected length + Assert.Equal(expectedDriverName, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(expectedVersion, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + // Low-priority fields should have been removed(arch and runtime) + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + + Assert.True(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out JsonElement os)); + Assert.True(os.TryGetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey, out JsonElement type)); + + Assert.Equal(expectedOsType, type.GetString()); + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + + } + + // DTO JSON contract - verify names and values(parameterized) + + /// + /// Verifies that UserAgentInfoDto serializes according to its JSON contract: + /// required fields always appear with correct values, optional fields + /// and the nested OS object are only emitted when non-null, + /// and all JSON property names match the defined constants. + /// + [Theory] + [InlineData("d", "v", "t", "dd", "a", "r")] + [InlineData("DeReaver", "1.2", "linux", "kernel", "", "")] + [InlineData("LongDrv", "2.0", "win", null, null, null)] + [InlineData("Driver", "Version", null, null, null, null)] // drop OsInfo entirely + public void Dto_JsonPropertyNames_MatchConstants( + string driver, + string version, + string? osType, + string? osDetails, + string? arch, + string? runtime) + { + // Arrange: build the DTO, dropping the OS object if osType is null + var dto = new UserAgentInfoDto + { + Driver = driver, + Version = version, + OS = osType == null + ? null + : new UserAgentInfoDto.OsInfo + { + Type = osType, + Details = string.IsNullOrEmpty(osDetails) ? null : osDetails + }, + Arch = string.IsNullOrEmpty(arch) ? null : arch, + Runtime = string.IsNullOrEmpty(runtime) ? null : runtime + }; + + // Arrange: configure JSON serialization to omit nulls and use exact property names + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + // Act: serialize the DTO and parse it back into a JsonDocument + string json = JsonSerializer.Serialize(dto, options); + using var doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + // Assert: required properties always present with correct values + Assert.Equal(driver, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(version, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + // Assert: Arch is only present if non-null + if (dto.Arch == null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.ArchJsonKey, out _)); + } + else + { + Assert.Equal(dto.Arch, root.GetProperty(UserAgentInfoDto.ArchJsonKey).GetString()); + } + + // Assert: Runtime is only present if non-null + if (dto.Runtime == null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.RuntimeJsonKey, out _)); + } + else + { + Assert.Equal(dto.Runtime, root.GetProperty(UserAgentInfoDto.RuntimeJsonKey).GetString()); + } + + // Assert: OS object may be omitted entirely + if (dto.OS == null) + { + Assert.False(root.TryGetProperty(UserAgentInfoDto.OsJsonKey, out _)); + } + else + { + JsonElement os = root.GetProperty(UserAgentInfoDto.OsJsonKey); + + // OS.Type must always be present when OS is not null + Assert.Equal(dto.OS.Type, os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); + + // OS.Details is optional + if (dto.OS.Details == null) + { + Assert.False(os.TryGetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey, out _)); + } + else + { + Assert.Equal(dto.OS.Details, os.GetProperty(UserAgentInfoDto.OsInfo.DetailsJsonKey).GetString()); + } + } + } + + // End-to-end test that combines truncation, adjustment, and serialization + [Fact] + public void EndToEnd_Truncate_Adjust_Serialize_Works() + { + string raw = new string('x', 2_000); + const int Max = 100; + + string driver = UserAgentInfo.TruncateOrDefault(raw, Max); + string version = UserAgentInfo.TruncateOrDefault(raw, Max); + string osType = UserAgentInfo.TruncateOrDefault(raw, Max); + + var dto = new UserAgentInfoDto + { + Driver = driver, + Version = version, + OS = new UserAgentInfoDto.OsInfo { Type = osType, Details = raw }, + Arch = raw, + Runtime = raw + }; + + byte[] payload = UserAgentInfo.AdjustJsonPayloadSize(dto); + string json = Encoding.UTF8.GetString(payload); + + using JsonDocument doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal(driver, root.GetProperty(UserAgentInfoDto.DriverJsonKey).GetString()); + Assert.Equal(version, root.GetProperty(UserAgentInfoDto.VersionJsonKey).GetString()); + + JsonElement os = root.GetProperty(UserAgentInfoDto.OsJsonKey); + Assert.Equal(osType, os.GetProperty(UserAgentInfoDto.OsInfo.TypeJsonKey).GetString()); + } + } +}