Skip to content

Commit 4e93076

Browse files
Add Json Payload Functionality for User Agent Feature Extension (#3582)
* Add Json Payload Functionality for User Agent Feature Extension * Update truncation null checks
1 parent bfa237f commit 4e93076

File tree

5 files changed

+757
-0
lines changed

5 files changed

+757
-0
lines changed

src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,12 @@
789789
<Compile Include="$(CommonSourceRoot)Microsoft\Data\SqlTypes\SqlVector.cs">
790790
<Link>Microsoft\Data\SqlTypes\SqlVector.cs</Link>
791791
</Compile>
792+
<Compile Include="$(CommonSourceRoot)Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs">
793+
<Link>Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs</Link>
794+
</Compile>
795+
<Compile Include="$(CommonSourceRoot)Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs">
796+
<Link>Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs</Link>
797+
</Compile>
792798
<Compile Include="$(CommonSourceRoot)Resources\ResCategoryAttribute.cs">
793799
<Link>Resources\ResCategoryAttribute.cs</Link>
794800
</Compile>

src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,12 @@
900900
<Compile Include="$(CommonSourceRoot)Microsoft\Data\SqlTypes\SqlVector.cs">
901901
<Link>Microsoft\Data\SqlTypes\SqlVector.cs</Link>
902902
</Compile>
903+
<Compile Include="$(CommonSourceRoot)Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs">
904+
<Link>Microsoft\Data\SqlClient\UserAgent\UserAgentInfo.cs</Link>
905+
</Compile>
906+
<Compile Include="$(CommonSourceRoot)Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs">
907+
<Link>Microsoft\Data\SqlClient\UserAgent\UserAgentInfoDto.cs</Link>
908+
</Compile>
903909
<Compile Include="$(CommonSourceRoot)Resources\ResDescriptionAttribute.cs">
904910
<Link>Resources\ResDescriptionAttribute.cs</Link>
905911
</Compile>
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Runtime.InteropServices;
7+
using System.Text.Json;
8+
using System.Text.Json.Serialization;
9+
using Microsoft.Data.Common;
10+
11+
#nullable enable
12+
13+
namespace Microsoft.Data.SqlClient.UserAgent;
14+
15+
/// <summary>
16+
/// Gathers driver + environment info, enforces size constraints,
17+
/// and serializes into a UTF-8 JSON payload.
18+
/// The spec document can be found at: https://microsoft.sharepoint-df.com/:w:/t/sqldevx/ERIWTt0zlCxLroNHyaPlKYwBI_LNSff6iy_wXZ8xX6nctQ?e=0hTJX7
19+
/// </summary>
20+
internal static class UserAgentInfo
21+
{
22+
/// <summary>
23+
/// Maximum number of characters allowed for the system architecture.
24+
/// </summary>
25+
private const int ArchMaxChars = 16;
26+
27+
/// <summary>
28+
/// Maximum number of characters allowed for the driver name.
29+
/// </summary>
30+
internal const int DriverNameMaxChars = 16;
31+
32+
/// <summary>
33+
/// Maximum number of bytes allowed for the user agent json payload.
34+
/// Payloads larger than this may be rejected by the server.
35+
/// </summary>
36+
internal const int JsonPayloadMaxBytes = 2047;
37+
38+
/// <summary>
39+
/// Maximum number of characters allowed for the operating system details.
40+
/// </summary>
41+
private const int OsDetailsMaxChars = 128;
42+
43+
/// <summary>
44+
/// Maximum number of characters allowed for the operating system type.
45+
/// </summary>
46+
internal const int OsTypeMaxChars = 16;
47+
48+
/// <summary>
49+
/// Maximum number of characters allowed for the driver runtime.
50+
/// </summary>
51+
private const int RuntimeMaxChars = 128;
52+
53+
/// <summary>
54+
/// Maximum number of characters allowed for the driver version.
55+
/// </summary>
56+
internal const int VersionMaxChars = 16;
57+
58+
59+
internal const string DefaultJsonValue = "Unknown";
60+
internal const string DriverName = "MS-MDS";
61+
62+
private static readonly UserAgentInfoDto s_dto;
63+
private static readonly byte[] s_userAgentCachedPayload;
64+
65+
/// <summary>
66+
/// Provides the UTF-8 encoded UserAgent JSON payload as a cached read-only memory buffer.
67+
/// The value is computed once during process initialization and reused across all calls.
68+
/// No re-encoding or recalculation occurs at access time, and the same memory is safely shared across all threads.
69+
/// </summary>
70+
public static ReadOnlyMemory<byte> UserAgentCachedJsonPayload => s_userAgentCachedPayload;
71+
72+
private enum OsType
73+
{
74+
Windows,
75+
Linux,
76+
macOS,
77+
FreeBSD,
78+
Android,
79+
Unknown
80+
}
81+
82+
static UserAgentInfo()
83+
{
84+
s_dto = BuildDto();
85+
s_userAgentCachedPayload = AdjustJsonPayloadSize(s_dto);
86+
}
87+
88+
/// <summary>
89+
/// This function returns the appropriately sized json payload
90+
/// We check the size of encoded json payload, if it is within limits we return the dto to be cached
91+
/// other wise we drop some fields to reduce the size of the payload.
92+
/// </summary>
93+
/// <param name="dto"> Data Transfer Object for the json payload </param>
94+
/// <returns>Serialized UTF-8 encoded json payload version of DTO within size limit</returns>
95+
internal static byte[] AdjustJsonPayloadSize(UserAgentInfoDto dto)
96+
{
97+
// Note: We serialize 6 fields in total:
98+
// - 4 fields with up to 16 characters each
99+
// - 2 fields with up to 128 characters each
100+
//
101+
// For estimating **on-the-wire UTF-8 size** of the serialized JSON:
102+
// 1) For the 4 fields of 16 characters:
103+
// - In worst case (all characters require escaping in JSON, e.g., quotes, backslashes, control chars),
104+
// each character may expand to 2–6 bytes in the JSON string (e.g., \" = 2 bytes, \uXXXX = 6 bytes)
105+
// - Assuming full escape with \uXXXX form (6 bytes per char): 4 × 16 × 6 = 384 bytes (extreme worst case)
106+
// - For unescaped high-plane Unicode (e.g., emojis), UTF-8 uses up to 4 bytes per character:
107+
// 4 × 16 × 4 = 256 bytes (UTF-8 max)
108+
//
109+
// Conservative max estimate for these fields = **384 bytes**
110+
//
111+
// 2) For the 2 fields of 128 characters:
112+
// - Worst-case with \uXXXX escape sequences: 2 × 128 × 6 = 1,536 bytes
113+
// - Worst-case with high Unicode: 2 × 128 × 4 = 1,024 bytes
114+
//
115+
// Conservative max estimate for these fields = **1,536 bytes**
116+
//
117+
// Combined worst-case for value content = 384 + 1536 = **1,920 bytes**
118+
//
119+
// 3) The rest of the serialized JSON payload (object braces, field names, quotes, colons, commas) is fixed.
120+
// Based on measurements, it typically adds to about **81 bytes**.
121+
//
122+
// Final worst-case estimate for total payload on the wire (UTF-8 encoded):
123+
// 1,920 + 81 = **2,001 bytes**
124+
//
125+
// This is still below our spec limit of 2,047 bytes.
126+
//
127+
// TDS Prelogin7 packets support up to 65,535 bytes (including headers), but many server versions impose
128+
// stricter limits for prelogin payloads.
129+
//
130+
// As a safety measure:
131+
// - If the serialized payload exceeds **10 KB**, we fallback to transmitting only essential fields:
132+
// 'driver', 'version', and 'os.type'
133+
// - If the payload exceeds 2,047 bytes but remains within sensible limits, we still send it, but note that
134+
// some servers may silently drop or reject such packets — behavior we may use for future probing or diagnostics.
135+
// - If payload exceeds 10KB even after dropping fields , we send an empty payload.
136+
var options = new JsonSerializerOptions
137+
{
138+
PropertyNamingPolicy = null,
139+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
140+
WriteIndented = false
141+
};
142+
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(dto, options);
143+
144+
// We try to send the payload if it is within the limits.
145+
// Otherwise we drop some fields to reduce the size of the payload and try one last time
146+
// Note: server will reject payloads larger than 2047 bytes
147+
// Try if the payload fits the max allowed bytes
148+
if (payload.Length <= JsonPayloadMaxBytes)
149+
{
150+
return payload;
151+
}
152+
153+
dto.Runtime = null; // drop Runtime
154+
dto.Arch = null; // drop Arch
155+
if (dto.OS != null)
156+
{
157+
dto.OS.Details = null; // drop OS.Details
158+
}
159+
160+
payload = JsonSerializer.SerializeToUtf8Bytes(dto, options);
161+
if (payload.Length <= JsonPayloadMaxBytes)
162+
{
163+
return payload;
164+
}
165+
166+
dto.OS = null; // drop OS entirely
167+
// Last attempt to send minimal payload driver + version only
168+
// As per the comment in AdjustJsonPayloadSize, we know driver + version cannot be larger than the max
169+
return JsonSerializer.SerializeToUtf8Bytes(dto, options);
170+
}
171+
172+
internal static UserAgentInfoDto BuildDto()
173+
{
174+
// Instantiate DTO before serializing
175+
return new UserAgentInfoDto
176+
{
177+
Driver = TruncateOrDefault(DriverName, DriverNameMaxChars),
178+
Version = TruncateOrDefault(ADP.GetAssemblyVersion().ToString(), VersionMaxChars),
179+
OS = new UserAgentInfoDto.OsInfo
180+
{
181+
Type = TruncateOrDefault(DetectOsType().ToString(), OsTypeMaxChars),
182+
Details = TruncateOrDefault(DetectOsDetails(), OsDetailsMaxChars)
183+
},
184+
Arch = TruncateOrDefault(DetectArchitecture(), ArchMaxChars),
185+
Runtime = TruncateOrDefault(DetectRuntime(), RuntimeMaxChars)
186+
};
187+
188+
}
189+
190+
/// <summary>
191+
/// Detects and reports whatever CPU architecture the guest OS exposes
192+
/// </summary>
193+
private static string DetectArchitecture()
194+
{
195+
try
196+
{
197+
// Returns the architecture of the current process (e.g., "X86", "X64", "Arm", "Arm64").
198+
// Note: This reflects the architecture of the running process, not the physical host system.
199+
return RuntimeInformation.ProcessArchitecture.ToString();
200+
}
201+
catch
202+
{
203+
// In case RuntimeInformation isn’t available or something unexpected happens
204+
return DefaultJsonValue;
205+
}
206+
}
207+
208+
/// <summary>
209+
/// Retrieves the operating system details based on RuntimeInformation.
210+
/// </summary>
211+
private static string DetectOsDetails()
212+
{
213+
var osDetails = RuntimeInformation.OSDescription;
214+
if (!string.IsNullOrWhiteSpace(osDetails))
215+
{
216+
return osDetails;
217+
}
218+
219+
return DefaultJsonValue;
220+
}
221+
222+
/// <summary>
223+
/// Detects the OS platform and returns the matching OsType enum.
224+
/// </summary>
225+
private static OsType DetectOsType()
226+
{
227+
try
228+
{
229+
// first we try with built-in checks (Android and FreeBSD also report Linux so they are checked first)
230+
#if NET6_0_OR_GREATER
231+
if (OperatingSystem.IsAndroid())
232+
{
233+
return OsType.Android;
234+
}
235+
if (OperatingSystem.IsFreeBSD())
236+
{
237+
return OsType.FreeBSD;
238+
}
239+
if (OperatingSystem.IsWindows())
240+
{
241+
return OsType.Windows;
242+
}
243+
if (OperatingSystem.IsLinux())
244+
{
245+
return OsType.Linux;
246+
}
247+
if (OperatingSystem.IsMacOS())
248+
{
249+
return OsType.macOS;
250+
}
251+
#endif
252+
253+
#if NET462
254+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD")))
255+
{
256+
return OsType.FreeBSD;
257+
}
258+
#else
259+
if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD))
260+
{
261+
return OsType.FreeBSD;
262+
}
263+
#endif
264+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
265+
{
266+
return OsType.Windows;
267+
}
268+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
269+
{
270+
return OsType.Linux;
271+
}
272+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
273+
{
274+
return OsType.macOS;
275+
}
276+
277+
// Final fallback is inspecting OSDecription
278+
// Note: This is not based on any formal specification,
279+
// that is why we use it as a last resort.
280+
// The string values are based on trial and error.
281+
var desc = RuntimeInformation.OSDescription?.ToLowerInvariant() ?? "";
282+
if (desc.Contains("android"))
283+
{
284+
return OsType.Android;
285+
}
286+
if (desc.Contains("freebsd"))
287+
{
288+
return OsType.FreeBSD;
289+
}
290+
if (desc.Contains("windows"))
291+
{
292+
return OsType.Windows;
293+
}
294+
if (desc.Contains("linux"))
295+
{
296+
return OsType.Linux;
297+
}
298+
if (desc.Contains("darwin") || desc.Contains("mac os"))
299+
{
300+
return OsType.macOS;
301+
}
302+
}
303+
catch
304+
{
305+
// swallow any unexpected errors
306+
return OsType.Unknown;
307+
}
308+
return OsType.Unknown;
309+
}
310+
311+
/// <summary>
312+
/// Returns the framework description as a string.
313+
/// </summary>
314+
private static string DetectRuntime()
315+
{
316+
// FrameworkDescription is never null, but IsNullOrWhiteSpace covers it anyway
317+
var desc = RuntimeInformation.FrameworkDescription;
318+
if (string.IsNullOrWhiteSpace(desc))
319+
{
320+
return DefaultJsonValue;
321+
}
322+
323+
// at this point, desc is non‑null, non‑empty (after trimming)
324+
return desc.Trim();
325+
}
326+
327+
/// <summary>
328+
/// Truncates a string to the specified maximum length or returns a default value if input is null or empty.
329+
/// </summary>
330+
/// <param name="jsonStringVal">The string value to truncate</param>
331+
/// <param name="maxChars">Maximum number of characters allowed</param>
332+
/// <returns>Truncated string or default value if input is invalid</returns>
333+
internal static string TruncateOrDefault(string? jsonStringVal, int maxChars)
334+
{
335+
try
336+
{
337+
if (string.IsNullOrEmpty(jsonStringVal))
338+
{
339+
return DefaultJsonValue;
340+
}
341+
342+
if (maxChars <= 0)
343+
{
344+
return DefaultJsonValue;
345+
}
346+
347+
if (jsonStringVal!.Length <= maxChars)
348+
{
349+
return jsonStringVal;
350+
}
351+
352+
return jsonStringVal.Substring(0, maxChars);
353+
}
354+
catch
355+
{
356+
// Silently consume all exceptions
357+
return DefaultJsonValue;
358+
}
359+
}
360+
361+
}

0 commit comments

Comments
 (0)