diff --git a/spec/user-agent-v1.jsonc b/spec/user-agent-v1.jsonc deleted file mode 100644 index d2c374df9f..0000000000 --- a/spec/user-agent-v1.jsonc +++ /dev/null @@ -1,94 +0,0 @@ -{ - // This is the JSON Schema that defines the format of the TDS USERAGENT - // Feature Extension payload (version 1) sent to the server during login. - // - // Feature Extension Name: USERAGENT - // Feature Extension Version: 1 - // Schema Version: 1 - // - // The design document for version 1 is here: - // - // https://microsoft.sharepoint-df.com/:w:/t/sqldevx/ERIWTt0zlCxLroNHyaPlKYwBI_LNSff6iy_wXZ8xX6nctQ?e=iP8q75 - - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:microsoft:mssql:user-agent:v1", - "title": "Driver User Agent V1", - "description": "The user agent payload sent by the driver during login.", - - "type": "object", - "properties": - { - "driver": - { - "enum": ["MS-JDBC", "MS-MDS", "MS-ODBC", "MS-OLEDB", "MS-PHP", "MS-PYTHON"], - "description": "The type of driver." - }, - "version": - { - "type": "string", - "description": "The version of the driver, as a semantic version.", - // See: - // - // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string - // - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" - }, - "os": - { - "type": "object", - "description": "Information about the operating system.", - "properties": - { - "type": - { - "enum": ["Windows", "Linux", "macOS", "FreeBSD", "Android", "Unknown"], - "description": "The type of operating system." - }, - "details": - { - "type": "string", - "description": "Extended details of the operating system." - } - }, - "additionalProperties": false, - "required": ["type", "details"] - }, - "arch": - { - "type": "string", - "description": "The architecture of the driver process." - }, - "runtime": - { - "type": "string", - "description": "The runtime environment of the driver." - } - }, - "additionalProperties": false, - "required": ["driver", "version", "os", "arch", "runtime"], - "examples": - [ - { - "driver": "MS-MDS", - "version": "6.0.2", - "os": - { - "type": "Linux", - "details": "Debian GNU Linux 12.2 Bookworm" - }, - "arch": "amd64", - "runtime": ".NET 9.0.3" - }, - { - "driver": "MS-JDBC", - "version": "11.2.0", - "os": - { - "type": "Windows", - "details": "Windows 10 Pro 22H2" - }, - "arch": "x64", - "runtime": "Java 17.0.8+7-LTS" - } - ] -} 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 32b9e76c98..ede9922e96 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -825,6 +825,9 @@ Microsoft\Data\SqlClient\TransactionRequest.cs + + Microsoft\Data\SqlClient\UserAgent.cs + Microsoft\Data\SqlClient\VirtualSecureModeEnclaveProvider.cs @@ -846,12 +849,6 @@ 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 4bfe45ee72..f93c8d4bb4 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj @@ -968,6 +968,9 @@ Microsoft\Data\SqlClient\TransactionRequest.cs + + Microsoft\Data\SqlClient\UserAgent.cs + Microsoft\Data\SqlClient\Utilities\BufferWriterExtensions.netfx.cs @@ -1001,12 +1004,6 @@ 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/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs index f2ca2db2cc..3a84ba050c 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs @@ -330,8 +330,11 @@ public static bool IgnoreServerProvidedFailoverPartner return s_ignoreServerProvidedFailoverPartner == Tristate.True; } } + /// - /// When set to true, the user agent feature is enabled and the driver will send the user agent string to the server. + /// When set to true, the user agent feature is enabled and the driver + /// will send the user agent string to the server as a LOGIN7 feature + /// extension. /// public static bool EnableUserAgent { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlError.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlError.cs index fc3a1247f3..a0d00956e9 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlError.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlError.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using Microsoft.Data.Common.ConnectionString; namespace Microsoft.Data.SqlClient { @@ -11,7 +12,7 @@ namespace Microsoft.Data.SqlClient public sealed class SqlError { // bug fix - MDAC 48965 - missing source of exception - private readonly string _source = TdsEnums.SQL_PROVIDER_NAME; + private readonly string _source = DbConnectionStringDefaults.ApplicationName; private readonly int _number; private readonly byte _state; private readonly byte _errorClass; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlException.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlException.cs index e070bb7d2e..772d6f2809 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlException.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlException.cs @@ -10,6 +10,7 @@ using System.Globalization; using System.Runtime.Serialization; using System.Text; +using Microsoft.Data.Common.ConnectionString; namespace Microsoft.Data.SqlClient { @@ -112,7 +113,7 @@ public override void GetObjectData(SerializationInfo si, StreamingContext contex public byte State => Errors.Count > 0 ? Errors[0].State : default; /// - override public string Source => TdsEnums.SQL_PROVIDER_NAME; + override public string Source => DbConnectionStringDefaults.ApplicationName; #if NET diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs index f6df0a82fe..8931f445be 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs @@ -13,10 +13,6 @@ namespace Microsoft.Data.SqlClient internal static class TdsEnums { // internal tdsparser constants - - - public const string SQL_PROVIDER_NAME = DbConnectionStringDefaults.ApplicationName; - public static readonly decimal SQL_SMALL_MONEY_MIN = new(-214748.3648); public static readonly decimal SQL_SMALL_MONEY_MAX = new(214748.3647); @@ -987,9 +983,6 @@ internal enum FedAuthInfoId : byte internal const byte MAX_SUPPORTED_VECTOR_VERSION = 0x01; internal const int VECTOR_HEADER_SIZE = 8; - // User Agent constants - internal const byte SUPPORTED_USER_AGENT_VERSION = 0x01; - // TCE Related constants internal const byte MAX_SUPPORTED_TCE_VERSION = 0x03; // max version internal const byte MIN_TCE_VERSION_WITH_ENCLAVE_SUPPORT = 0x02; // min version with enclave support diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index 115c62f6c5..604d15797e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -18,13 +18,13 @@ using System.Xml; using Interop.Common.Sni; using Microsoft.Data.Common; +using Microsoft.Data.Common.ConnectionString; using Microsoft.Data.ProviderBase; using Microsoft.Data.Sql; using Microsoft.Data.SqlClient.Connection; using Microsoft.Data.SqlClient.DataClassification; using Microsoft.Data.SqlClient.LocalDb; using Microsoft.Data.SqlClient.Server; -using Microsoft.Data.SqlClient.UserAgent; using Microsoft.Data.SqlClient.Utilities; using Microsoft.SqlServer.Server; @@ -1305,11 +1305,11 @@ internal void TdsLogin( // length in bytes int length = TdsEnums.SQL2005_LOG_REC_FIXED_LEN; - string clientInterfaceName = TdsEnums.SQL_PROVIDER_NAME; - Debug.Assert(TdsEnums.MAXLEN_CLIENTINTERFACE >= clientInterfaceName.Length, "cchCltIntName can specify at most 128 unicode characters. See Tds spec"); + // Obtain the client interface name. + string clientInterfaceName = DbConnectionStringDefaults.ApplicationName; + Debug.Assert(clientInterfaceName.Length <= TdsEnums.MAXLEN_CLIENTINTERFACE); // add up variable-len portions (multiply by 2 for byte len of char strings) - // checked { length += (rec.hostName.Length + rec.applicationName.Length + @@ -1364,7 +1364,7 @@ internal void TdsLogin( requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + UserAgent.Ucs2Bytes, useFeatureExt, length ); @@ -9172,29 +9172,30 @@ internal int WriteVectorSupportFeatureRequest(bool write) } /// - /// Writes the User Agent feature request to the physical state object. - /// The request includes the feature ID, feature data length, version number and encoded JSON payload. + /// Writes the User Agent feature request to the physical state + /// object. The request includes the feature ID, feature data length, + /// and UCS-2 little-endian encoded payload. /// - /// Byte array of UTF-8 encoded JSON payload for User Agent + /// + /// The feature request consists of: + /// - 1 byte for the feature ID. + /// - 4 bytes for the feature data length. + /// - N bytes for the UCS-2 payload + /// + /// + /// UCS-2 little-endian encoded UserAgent payload. + /// /// - /// If true, writes the feature request to the physical state object. - /// If false, just calculates the length. + /// If true, writes the feature request to the physical state object. + /// If false, just calculates the length. /// /// The length of the feature request in bytes. - /// - /// The feature request consists of: - /// - 1 byte for the feature ID. - /// - 4 bytes for the feature data length. - /// - 1 byte for the version number. - /// - N bytes for the JSON payload - /// - internal int WriteUserAgentFeatureRequest(byte[] userAgentJsonPayload, + internal int WriteUserAgentFeatureRequest(ReadOnlyMemory userAgent, bool write) { - // 1byte (Feature Version) + size of UTF-8 encoded JSON payload - int dataLen = 1 + userAgentJsonPayload.Length; - // 1byte (Feature ID) + 4bytes (Feature Data Length) + 1byte (Version) + N(JSON payload size) - int totalLen = 1 + 4 + dataLen; + // 1 byte (Feature ID) + 4 bytes (Feature Data Length) + N bytes + // (UCS-2 payload size) + int totalLen = 1 + 4 + userAgent.Length; if (write) { @@ -9202,13 +9203,10 @@ internal int WriteUserAgentFeatureRequest(byte[] userAgentJsonPayload, _physicalStateObj.WriteByte(TdsEnums.FEATUREEXT_USERAGENT); // Feature Data Length - WriteInt(dataLen, _physicalStateObj); - - // Write Feature Version - _physicalStateObj.WriteByte(TdsEnums.SUPPORTED_USER_AGENT_VERSION); + WriteInt(userAgent.Length, _physicalStateObj); - // Write encoded JSON payload - _physicalStateObj.WriteByteArray(userAgentJsonPayload, userAgentJsonPayload.Length, 0); + // Write encoded UCS-2 payload + _physicalStateObj.WriteByteSpan(userAgent.Span); } return totalLen; @@ -9488,7 +9486,7 @@ private void WriteLoginData(SqlLogin rec, requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgentInfo.UserAgentCachedJsonPayload.ToArray(), + UserAgent.Ucs2Bytes, useFeatureExt, length, true @@ -9511,7 +9509,7 @@ private void WriteLoginData(SqlLogin rec, private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, SessionData recoverySessionData, FederatedAuthenticationFeatureExtensionData fedAuthFeatureExtensionData, - byte[] userAgentJsonPayload, + ReadOnlyMemory userAgent, bool useFeatureExt, int length, bool write = false) @@ -9523,7 +9521,7 @@ private int ApplyFeatureExData(TdsEnums.FeatureExtension requestedFeatures, // NOTE: As part of TDS spec UserAgent feature extension should be the first feature extension in the list. if (LocalAppContextSwitches.EnableUserAgent && ((requestedFeatures & TdsEnums.FeatureExtension.UserAgent) != 0)) { - length += WriteUserAgentFeatureRequest(userAgentJsonPayload, write); + length += WriteUserAgentFeatureRequest(userAgent, write); } if ((requestedFeatures & TdsEnums.FeatureExtension.SessionRecovery) != 0) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs new file mode 100644 index 0000000000..55bc3b8208 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs @@ -0,0 +1,433 @@ +// 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; + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +/// This class uses runtime environment information to produce a value +/// suitable for use in the TDS LOGIN7 USERAGENT Feature Extension +/// payload. +/// +/// See the spec here: +/// +/// +/// SQL Drivers User Agent V1 +/// +/// +internal static class UserAgent +{ + #region Properties + + /// + /// + /// The pipe-delimited payload as a string, never null, never empty, and + /// never larger than 256 characters. + /// + /// + /// The format is pipe ('|') delimited into 7 parts: + /// + /// 1|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + /// + /// + /// The {Driver Version} part is the version of the driver, + /// sourced from the MDS NuGet package version in SemVer 2.0 format. + /// Maximum length is 24 characters. + /// + /// + /// The {Arch} part will be the process architecture, either + /// the bare metal hardware architecture or the virtualized + /// architecture. See + /// + /// ProcessArchitecture + /// + /// for possible values. Maximum length is 10 characters. + /// + /// + /// The {OS Type} part will be one of the following strings: + /// + /// Windows + /// Linux + /// macOS + /// FreeBSD + /// Unknown + /// + /// + /// + /// The {OS Info} part will be sourced from the + /// + /// OSDescription + /// + /// value, or "Unknown" if that value is empty or all whitespace. + /// Maximum length is 44 characters. + /// + /// + /// The {Runtime Info} part will be sourced from the + /// + /// FrameworkDescription + /// + /// value, or "Unknown" if that value is empty or all whitespace. + /// Maximum length is 44 characters. + /// + /// + /// Any characters from the sourced values that are not one of the + /// following are replaced with underscore ('_'): + /// + /// + /// ASCII letters ([A-za-z]) + /// + /// ASCII digits ([0-9]) + /// Space (' ') + /// Period ('.') + /// Plus ('+') + /// Underscore ('_') + /// Hyphen ('-') + /// + /// + /// + /// All known exceptions are caught and handled by injecting the + /// fallback value of "Unknown". However, no effort is made to + /// catch all exceptions, for example process-fatal memory + /// allocation errors. + /// + /// + internal static string Value { get; } + + /// + /// The Value as UCS-2 encoded bytes. + /// + internal static ReadOnlyMemory Ucs2Bytes { get; } + + #endregion Properties + + #region Helpers + + /// + /// Static construction builds the Client Interface Name. All known + /// exceptions are consumed. + /// + static UserAgent() + { + // Determine the OS type. + // + // This is done outside of Build() to allow tests to inject + // specific values. + // + string osType = Unknown; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + osType = Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + osType = Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + osType = macOS; + } + // The FreeBSD platform doesn't exist in .NET Framework at all. + #if NET + else if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) + { + osType = FreeBSD; + } + #endif + + // Build it! + Value = Build( + MaxLenOverall, + PayloadVersion, + DriverName, + System.ThisAssembly.NuGetPackageVersion, + RuntimeInformation.ProcessArchitecture, + osType, + RuntimeInformation.OSDescription, + RuntimeInformation.FrameworkDescription); + + // Convert it to UCS-2 bytes. + // + // The default Unicode instance doesn't throw if encoding fails, so + // there is nothing to catch here. + Ucs2Bytes = Encoding.Unicode.GetBytes(Value); + } + + /// + /// Build the payload string value and return it. + /// + /// The length of the returned value will never be longer than + /// . + /// + /// All known exceptions are consumed. + /// + /// + /// The maximum length of the returned value. + /// + /// + /// The value of the payload version part. + /// + /// + /// The value of the driver name part. + /// + /// + /// The value of the driver version part. + /// + /// + /// The value of the Architecture part. + /// + /// + /// The value of the OS Type part. + /// + /// + /// The value of the OS Info part. + /// + /// + /// The value of the Runtime Info part. + /// + /// + /// The payload string value, never null, never empty, and never longer + /// than . + /// + internal static string Build( + ushort maxLen, + string payloadVersion, + string driverName, + string driverVersion, + Architecture arch, + string osType, + string osInfo, + string runtimeInfo) + { + string result; + + // Clean and truncate the payload version and driver name. We will need + // them for error handling. + payloadVersion = Truncate(Clean(payloadVersion), MaxLenPayloadVersion); + driverName = Truncate(Clean(driverName), MaxLenDriverName); + + try + { + // Expect to build a string whose length is up to our max length. + // + // This isn't a max capacity, but a hint for initial buffer + // allocation. We will truncate to our max length after all of the + // pieces have been appended. + // + StringBuilder name = new StringBuilder(maxLen); + + // Start with the (clean) payload version and driver name. + name.Append(payloadVersion); + name.Append('|'); + name.Append(driverName); + name.Append('|'); + + // Add the Driver Version, truncating to its max length. + name.Append(Truncate(Clean(driverVersion), MaxLenDriverVersion)); + name.Append('|'); + + // Add the Architecture, truncating to its max length. + name.Append(Truncate(Clean(arch.ToString()), MaxLenArch)); + name.Append('|'); + + // Add the OS Type, truncating to its max length. + name.Append(Truncate(Clean(osType), MaxLenOsType)); + name.Append('|'); + + // Add the OS Info, truncating to its max length. + name.Append(Truncate(Clean(osInfo), MaxLenOsInfo)); + name.Append('|'); + + // Add the Runtime Info, truncating to its max length. + name.Append(Truncate(Clean(runtimeInfo), MaxLenRuntimeInfo)); + + // Remember the name we've built up. + result = name.ToString(); + } + catch (ArgumentOutOfRangeException) + { + // StringBuilder failed in an unexpected way, so use our fallback + // value. + result = + $"{payloadVersion}|{driverName}|{Unknown}|{Unknown}|" + + $"{Unknown}|{Unknown}|{Unknown}"; + } + + // Truncate to our max length if necessary. + // + // This is a paranoia check to ensure we don't violate our API + // promise. + // + if (result.Length > maxLen) + { + // We know this won't throw ArgumentOutOfRangeException because + // we've already confirmed that Length is greater than maxLen. + result = result.Substring(0, maxLen); + } + + return result; + } + + /// + /// + /// Clean the given value of any disallowed characters, replacing them + /// with underscore ('_'), and return the cleaned value. + /// + /// Leading and trailing whitespace are removed. + /// + /// Each disallowed character is replaced with an underscore, preserving + /// the original length of the value. No effort is made to collapse + /// adjacent disallowed characters. + /// + /// + /// Permitted characters are: + /// + /// + /// ASCII letters ([A-za-z]) + /// + /// ASCII digits ([0-9]) + /// Space (' ') + /// Period ('.') + /// Plus ('+') + /// Underscore ('_') + /// Hyphen ('-') + /// + /// + /// + /// If the given value is null, empty, or all whitespace, or an error + /// occurs, the fallback value is returned. + /// + /// + /// The value to clean. + /// + /// The cleaned value, or the fallback value if any errors occur. + /// + internal static string Clean(string? value) + { + if (string.IsNullOrWhiteSpace(value) + // .NET Framework doesn't consider IsNullOrWhiteSpace() + // sufficient for nullable checks, so add an explicit check for + // null. + #if NETFRAMEWORK + || value == null + #endif + ) + { + return Unknown; + } + + // Remove any leading and trailing whitespace. + value = value.Trim(); + + try + { + // Build the cleaned value by hand, avoiding the overhead and + // failure scenarios of regexes or other more complex solutions. + // + // We expect the value to be short, and this code is called only a + // few times per process. Robustness and simplicity are more + // important than performance here. + // + StringBuilder cleaned = new StringBuilder(value.Length); + foreach (char c in value) + { + // Is it a permitted character? + if ( + #if NET + char.IsAsciiLetter(c) + || char.IsAsciiDigit(c) + #else + (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + #endif + || c == ' ' + || c == '.' + || c == '+' + || c == '_' + || c == '-') + { + // Yes, so append it as-is. + cleaned.Append(c); + } + else + { + // No, so replace it with an underscore. + cleaned.Append('_'); + } + } + + return cleaned.ToString(); + } + catch (ArgumentOutOfRangeException) + { + // StringBuilder failed in an unexpected way, so use our fallback + // value. + return Unknown; + } + } + + /// + /// Truncate the given value to the given max length, and return the + /// result. + /// + /// The value to truncate. + /// The maximum length to truncate to. + /// + /// The truncated value, or the original if no + /// truncation occurred. + /// + internal static string Truncate(string value, ushort maxLength) + { + if (value.Length <= maxLength) + { + return value; + } + + // We know this won't throw ArgumentOutOfRangeException because we've + // already confirmed that Length is greater than maxLength. + return value.Substring(0, maxLength); + } + + #endregion Helpers + + #region Private Fields + + // Our payload format version. + private const string PayloadVersion = "1"; + + // Our well-known .NET driver name. + private const string DriverName = "MS-MDS"; + + // The overall maximum length of Value. + private const ushort MaxLenOverall = 256; + + // Maximum part lengths as promised in our API. + private const ushort MaxLenPayloadVersion = 2; + private const ushort MaxLenDriverName = 12; + private const ushort MaxLenDriverVersion = 24; + private const ushort MaxLenArch = 10; + private const ushort MaxLenOsType = 10; + private const ushort MaxLenOsInfo = 44; + private const ushort MaxLenRuntimeInfo = 44; + + // The OS Type values we promise in our API. + private const string Windows = "Windows"; + private const string Linux = "Linux"; + private const string macOS = "macOS"; + // The FreeBSD platform doesn't exist in .NET Framework at all. + #if NET + private const string FreeBSD = "FreeBSD"; + #endif + + // A fallback value for parts of the client interface name that are + // unknown, invalid, or when errors occur. + private const string Unknown = "Unknown"; + + #endregion Private Fields +} 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 deleted file mode 100644 index d927155dbb..0000000000 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfo.cs +++ /dev/null @@ -1,361 +0,0 @@ -// 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 deleted file mode 100644 index 2c61d1c4bb..0000000000 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent/UserAgentInfoDto.cs +++ /dev/null @@ -1,53 +0,0 @@ -// 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/Microsoft/Data/SqlClient/UdtSerialization/NativeSerializationTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtSerialization/NativeSerializationTest.cs index 3369a66985..bcc1acbc2e 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtSerialization/NativeSerializationTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtSerialization/NativeSerializationTest.cs @@ -384,7 +384,9 @@ public static TheoryData SerializedNullPrimitiveTypeValues() => /// Primitive to serialize and to compare against. /// Expected byte output. [Theory] - [MemberData(nameof(SerializedNonNullPrimitiveTypeValues))] + [MemberData( + nameof(SerializedNonNullPrimitiveTypeValues), + DisableDiscoveryEnumeration = true)] public void Serialize_PrimitiveType_Roundtrips(object primitive, byte[] expectedValue) => RoundtripType(primitive, expectedValue); @@ -395,7 +397,9 @@ public void Serialize_PrimitiveType_Roundtrips(object primitive, byte[] expected /// Primitive to serialize and to compare against. /// Expected byte output. [Theory] - [MemberData(nameof(SerializedNestedNonNullPrimitiveTypeValues))] + [MemberData( + nameof(SerializedNestedNonNullPrimitiveTypeValues), + DisableDiscoveryEnumeration = true)] public void Serialize_NestedPrimitiveType_Roundtrips(object primitive, byte[] expectedValue) => RoundtripType(primitive, expectedValue); @@ -406,7 +410,9 @@ public void Serialize_NestedPrimitiveType_Roundtrips(object primitive, byte[] ex /// Primitive to serialize and to compare against. /// Expected byte output. [Theory] - [MemberData(nameof(SerializedNullPrimitiveTypeValues))] + [MemberData( + nameof(SerializedNullPrimitiveTypeValues), + DisableDiscoveryEnumeration = true)] public void Serialize_NullPrimitiveType_Roundtrips(object primitive, byte[] expectedValue) => RoundtripType(primitive, expectedValue); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index 9dad33aa1d..ff85bf87d3 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -830,35 +830,35 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR } } - // Test to verify that the client sends a UserAgent version - // and driver behaves correctly even if server sent an Ack + // Test that the driver sends the UserAgent feature extension when + // the context switch is enabled, and that the presence or absence of + // an ack from the server has no effect. [Theory] - [InlineData(false)] // We do not force test server to send an Ack - [InlineData(true)] // Server is forced to send an Ack - public void TestConnWithUserAgentFeatureExtension(bool forceAck) + // Allow the server to ack. + [InlineData(true)] + // Don't allow the server to send an ack. + [InlineData(false)] + public void TestConnWithUserAgentFeatureExtension(bool sendAck) { - // Make sure needed switch is enabled + // Enable sending the UserAgent if desired. using LocalAppContextSwitchesHelper switchesHelper = new(); switchesHelper.EnableUserAgentField = LocalAppContextSwitchesHelper.Tristate.True; - using var server = new TdsServer(); + // Start the test server. + using TdsServer server = new(); server.Start(); - // Configure the server to support UserAgent version 0x01 - server.ServerSupportedUserAgentFeatureExtVersion = 0x01; - - // Opt in to forced ACK for UserAgentSupport (no negotiation) - server.EnableUserAgentFeatureExt = forceAck; + // Configure the server to send a UserAgent ack if desired. + server.EnableUserAgentFeatureExt = sendAck; bool loginFound = false; // Captured from LOGIN7 as parsed by the test server - byte observedVersion = 0; - byte[] observedJsonBytes = Array.Empty(); + byte[] observedPayload = Array.Empty(); bool firstFeatureIsUserAgent = false; bool tokenWasNotNull = false; - bool dataLengthAtLeast2 = false; + bool dataLengthAtLeast1 = false; // Inspect what the client sends in the LOGIN7 packet server.OnLogin7Validated = loginToken => @@ -872,16 +872,11 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) tokenWasNotNull = token is not null; var data = token?.Data ?? Array.Empty(); - dataLengthAtLeast2 = data.Length >= 2; if (data.Length >= 1) { - observedVersion = data[0]; - } - - if (data.Length >= 2) - { - observedJsonBytes = data.AsSpan(1).ToArray(); + dataLengthAtLeast1 = true; + observedPayload = data.AsSpan().ToArray(); } loginFound = true; @@ -894,7 +889,6 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) Encrypt = SqlConnectionEncryptOption.Optional, }.ConnectionString; - // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds using var connection = new SqlConnection(connStr); connection.Open(); @@ -905,26 +899,10 @@ public void TestConnWithUserAgentFeatureExtension(bool forceAck) Assert.True(loginFound, "Expected UserAgent extension in LOGIN7"); Assert.True(firstFeatureIsUserAgent); Assert.True(tokenWasNotNull); - Assert.True(dataLengthAtLeast2); - Assert.Equal(0x1, observedVersion); - - // Note: Accessing UserAgentInfo via Reflection. - // We cannot use InternalsVisibleTo here because making internals visible to FunctionalTests - // causes the *.TestHarness.cs stubs to clash with the real internal types in SqlClient. - var asm = typeof(SqlConnection).Assembly; - var userAgentInfoType = - asm.GetTypes().FirstOrDefault(t => string.Equals(t.Name, "UserAgentInfo", StringComparison.Ordinal)) ?? - asm.GetTypes().FirstOrDefault(t => t.FullName?.EndsWith(".UserAgentInfo", StringComparison.Ordinal) == true); - - Assert.NotNull(userAgentInfoType); - - // Try to get the property - var prop = userAgentInfoType.GetProperty("UserAgentCachedJsonPayload", - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - Assert.NotNull(prop); - - ReadOnlyMemory cachedPayload = (ReadOnlyMemory)prop.GetValue(null)!; - Assert.Equal(cachedPayload.ToArray(), observedJsonBytes.ToArray()); + Assert.True(dataLengthAtLeast1); + Assert.Equal(UserAgent.Ucs2Bytes.ToArray(), observedPayload); + + // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds } /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/TdsParserInternalsTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/TdsParserInternalsTest.cs index 17a4ad8b5a..a922507935 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/TdsParserInternalsTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/TdsParserInternalsTest.cs @@ -46,16 +46,15 @@ private static (byte[] buffer, int count) ExtractOutputBuffer(TdsParser parser) [Fact] public void WriteUserAgentFeatureRequest_WriteFalse_LengthOnlyReturn() { - byte[] payload = Encoding.UTF8.GetBytes("{\"kel\":\"sier\"}"); + byte[] payload = Encoding.UTF8.GetBytes("User-Agent-Payload"); var (_, countBefore) = ExtractOutputBuffer(_parser); int lengthOnly = _parser.WriteUserAgentFeatureRequest(payload, write: false); var (_, countAfter) = ExtractOutputBuffer(_parser); - // assert: total = 1 (feat-ID) + 4 (len field) + [1 (version) + payload.Length] - int expectedDataLen = 1 + payload.Length; - int expectedTotalLen = 1 + 4 + expectedDataLen; + // assert: total = 1 (feat-ID) + 4 (len field) + payload.Length + int expectedTotalLen = 1 + 4 + payload.Length; Assert.Equal(expectedTotalLen, lengthOnly); // assert: no bytes were written when write == false @@ -65,7 +64,7 @@ public void WriteUserAgentFeatureRequest_WriteFalse_LengthOnlyReturn() [Fact] public void WriteUserAgentFeatureRequest_WriteTrue_AppendsOnlyExtensionBytes() { - byte[] payload = Encoding.UTF8.GetBytes("{\"kel\":\"sier\"}"); + byte[] payload = Encoding.UTF8.GetBytes("User-Agent-Payload"); var (bufferBefore, countBefore) = ExtractOutputBuffer(_parser); int returnedLength = _parser.WriteUserAgentFeatureRequest(payload, write: true); @@ -84,23 +83,17 @@ public void WriteUserAgentFeatureRequest_WriteTrue_AppendsOnlyExtensionBytes() bufferAfter[start]); int dataLenFromStream = BitConverter.ToInt32(bufferAfter, start + 1); - int expectedDataLen = 1 + payload.Length; - Assert.Equal(expectedDataLen, dataLenFromStream); - - Assert.Equal( - TdsEnums.SUPPORTED_USER_AGENT_VERSION, - bufferAfter[start + 5]); + Assert.Equal(payload.Length, dataLenFromStream); // slice into the existing buffer ReadOnlySpan writtenSpan = new( bufferAfter, - start + 6, - appended - 6); + start + 5, + appended - 5); Assert.True( writtenSpan.SequenceEqual(payload), "Payload bytes did not match"); - } } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs deleted file mode 100644 index d45c188c43..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentInfoTests.cs +++ /dev/null @@ -1,331 +0,0 @@ -// 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()); - } - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs new file mode 100644 index 0000000000..771323db29 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs @@ -0,0 +1,567 @@ +// 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; + +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Data.SqlClient.Tests; + +public sealed class UserAgentTests +{ + #region Constants + + // All permitted characters that may appear as values in the User Agent. + const string AllPermitted = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz" + + "0123456789" + + " .+_-"; + + #endregion + + #region Test Setup + + /// + /// Setup to test by saving the xUnit output helper. + /// + /// The xUnit output helper. + public UserAgentTests(ITestOutputHelper output) + { + // Use the dotnet CLI --logger option to see the output for successful + // test runs: + // + // dotnet test --logger "console;verbosity=detailed" + // + // The output will appear by default if a test fails. + // + _output = output; + } + + #endregion Test Setup + + #region Tests + + /// + /// Test the Value property when actual runtime information is used. + /// + /// This test assumes that values returned by the runtime used to construct + /// the Value property will all fit within the max length (currently 256 + /// characters). + /// + /// If this test fails, then either the max length has changed or the + /// runtime values have changed in a meaningful way. + /// + [Fact] + public void Value_Runtime_Parts() + { + string value = UserAgent.Value; + + _output.WriteLine($"UserAgent.Value: {value}"); + + // Check the basic properties of the value. + Assert.NotNull(value); + Assert.True(value.Length > 0); + Assert.True(value.Length <= 256); + + // Ensure we can split it into the expected parts. + // + // The format should be: + // + // 1|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + // + var parts = value.Split('|'); + Assert.Equal(7, parts.Length); + Assert.Equal("1", parts[0]); + Assert.Equal("MS-MDS", parts[1]); + Assert.Equal(System.ThisAssembly.NuGetPackageVersion, parts[2]); + + // Architecture must be non-empty and 10 characters or less. + Assert.True(parts[3] == "Unknown" || parts[3].Length > 0); + Assert.True(parts[3].Length <= 10); + + // Check the OS Type against the guaranteed values. + var osType = parts[4]; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Equal("Windows", osType); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Assert.Equal("Linux", osType); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Assert.Equal("macOS", osType); + } + #if NET + else if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) + { + Assert.Equal("FreeBSD", osType); + } + #endif + else + { + Assert.Equal("Unknown", osType); + } + + // OS Info must be non-empty and 44 characters or less. + Assert.True(parts[5] == "Unknown" || parts[5].Length > 0); + Assert.True(parts[5].Length <= 44); + + // Runtime Info must be non-empty and 44 characters or less. + Assert.True(parts[6] == "Unknown" || parts[6].Length > 0); + Assert.True(parts[6].Length <= 44); + } + + /// + /// Test the Ucs2Bytes property when actual runtime information is used. + /// + [Fact] + public void Ucs2Bytes_Runtime_Parts() + { + var bytes = UserAgent.Ucs2Bytes; + + #if NET + var hex = Convert.ToHexString(bytes.Span); + #else + var hex = BitConverter.ToString(bytes.ToArray()).Replace("-", string.Empty); + #endif + + _output.WriteLine($"UserAgent.Ucs2Bytes: 0x{hex}"); + + // Check the basic properties of the byte array. + Assert.True(bytes.Length > 0); + Assert.True(bytes.Length <= 256 * 2); // UCS-2 uses 2 bytes per char. + + // Ensure we can convert the bytes back to the original string. + string value = + #if NET + Encoding.Unicode.GetString(bytes.Span); + #else + Encoding.Unicode.GetString(bytes.ToArray()); + #endif + + Assert.Equal(UserAgent.Value, value); + } + + /// + /// Test the Build() function when it truncates the overall length. + /// + /// The expected max payload length. + /// The expected payload string. + [Theory] + [InlineData(0, "")] + [InlineData(1, "2")] + [InlineData(2, "2|")] + [InlineData(3, "2|A")] + [InlineData(4, "2|A|")] + [InlineData(5, "2|A|B")] + [InlineData(6, "2|A|B|")] + [InlineData(7, "2|A|B|X")] + [InlineData(8, "2|A|B|X6")] + [InlineData(9, "2|A|B|X64")] + [InlineData(10, "2|A|B|X64|")] + [InlineData(11, "2|A|B|X64|C")] + [InlineData(12, "2|A|B|X64|C|")] + [InlineData(13, "2|A|B|X64|C|D")] + [InlineData(14, "2|A|B|X64|C|D|")] + [InlineData(15, "2|A|B|X64|C|D|E")] + public void Build_Truncate_Overall(ushort maxLen, string expected) + { + Assert.Equal( + expected, + UserAgent.Build( + maxLen, + payloadVersion: "2", + driverName: "A", + driverVersion: "B", + Architecture.X64, + osType: "C", + osInfo: "D", + runtimeInfo: "E")); + } + + /// + /// Test the Build() function when it truncates the payload version. + /// + [Fact] + public void Build_Truncate_Payload_Version() + { + // The payload version is longer than max length. + Assert.Equal( + "P", + UserAgent.Build( + 1, "PV", "A", "B", Architecture.X64, "C", "D", "E")); + + // The payload version is longer than its per-field max length of 2. + Assert.Equal( + "12|A|B|X64|C|D|E", + UserAgent.Build( + 128, "1234", "A", "B", Architecture.X64, "C", "D", "E")); + } + + /// + /// Test the Build() function when it truncates the driver name. + /// + [Fact] + public void Build_Truncate_Driver_Name() + { + // The driver name is longer than max length. + Assert.Equal( + "2|DriverNa", + UserAgent.Build( + 10, "2", "DriverName", "B", Architecture.X64, "C", "D", "E")); + + // The driver name is longer than its per-field max length of 12. + Assert.Equal( + "2|LongDriverNa|B|X64|C|D|E", + UserAgent.Build( + 128, "2", "LongDriverName", "B", Architecture.X64, "C", + "D", "E")); + } + + /// + /// Test the Build() function when it truncates the driver version. + /// + [Fact] + public void Build_Truncate_Driver_Version() + { + // The driver version is longer than max length. + Assert.Equal( + "2|A|DriverVe", + UserAgent.Build( + 12, "2", "A", "DriverVersion", Architecture.X64, "C", "D", + "E")); + + // The driver version is longer than its per-field max length of 24. + Assert.Equal( + "2|A|ReallyLongDriverVersionS|X64|C|D|E", + UserAgent.Build( + 128, "2", "A", "ReallyLongDriverVersionString", + Architecture.X64, "C", "D", "E")); + } + + /// + /// Test the Build() function when it truncates the Architecture. + /// + [Fact] + public void Build_Truncate_Arch() + { + // The Architecture puts the overall length over the max. + Assert.Equal( + "2|A|B|Arm6", + UserAgent.Build( + 10, "2", "A", "B", Architecture.Arm64, "C", "D", "E")); + + // There are no Architecture enum values defined in .NET Framework + // with a length longer than 10, so we can only check truncation + // in .NET. + #if NET + // The Architecture is longer than its per-field max length of 10. + Assert.Equal( + "2|A|B|LoongArch6|C|D|E", + UserAgent.Build( + 128, "2", "A", "B", Architecture.LoongArch64, "C", "D", "E")); + #endif + } + + /// + /// Test the Build() function when it truncates the OS Type. + /// + [Fact] + public void Build_Truncate_OS_Type() + { + // The OS Type puts the overall length over the max. + Assert.Equal( + "2|A|B|X64|LongOs", + UserAgent.Build( + 16, "2", "A", "B", Architecture.X64, "LongOsName", "D", "E")); + + // The OS Type is longer than its per-field max length of 10. + Assert.Equal( + "2|A|B|X64|VeryLongOs|D|E", + UserAgent.Build( + 128, "2", "A", "B", Architecture.X64, "VeryLongOsName", "D", + "E")); + } + + /// + /// Test the Build() function when it truncates the OS Info. + /// + [Fact] + public void Build_Truncate_OS_Info() + { + // The OS Info puts the overall length over the max. + Assert.Equal( + "2|A|B|X64|C|LongOsI", + UserAgent.Build( + 19, "2", "A", "B", Architecture.X64, "C", "LongOsInfo", "E")); + + // The OS Type is longer than its per-field max length of 44. + Assert.Equal( + "2|A|B|X64|C|01234567890123456789012345678901234567890123|E", + UserAgent.Build( + 128, "2", "A", "B", Architecture.X64, "C", + "01234567890123456789012345678901234567890123456789", + "E")); + } + + /// + /// Test the Build() function when it truncates the Runtime Info. + /// + [Fact] + public void Build_Truncate_Runtime_Info() + { + // The Runtime Info puts the overall length over the max. + Assert.Equal( + "2|A|B|X64|C|D|LongRunt", + UserAgent.Build( + 22, "2", "A", "B", Architecture.X64, "C", "D", + "LongRuntimeInfo")); + + // The Runtime Type is longer than its per-field max length of 44. + Assert.Equal( + "2|A|B|X64|C|D|01234567890123456789012345678901234567890123", + UserAgent.Build( + 128, "2", "A", "B", Architecture.X64, "C", "D", + "01234567890123456789012345678901234567890123456789")); + } + + /// + /// Test the Build() function when most of the fields are truncated, and the + /// overall length is still within the max. + /// + [Fact] + public void Build_Truncate_Most() + { + var name = + UserAgent.Build( + 192, + // Payload version > 2 chars. + "1234", + // Driver name > 12 chars. + "A01234567890123456789", + // Driver version > 24 chars. + "B012345678901234567890123456789", + // Architecture isn't truncated (because .NET Framework + // doesn't have any enum values long enough). + Architecture.X64, + // OS Type > 10 chars. + "C01234567890123456789", + // OS Info > 44 chars. + "D01234567890123456789012345678901234567890123456789", + // Runtime Info > 44 chars. + "E01234567890123456789012345678901234567890123456789"); + Assert.Equal(145, name.Length); + Assert.Equal( + "12|" + + "A01234567890|" + + "B01234567890123456789012|" + + "X64|" + + "C012345678|" + + "D0123456789012345678901234567890123456789012|" + + "E0123456789012345678901234567890123456789012", + name); + } + + // Only .NET has an Architecture enum value long enough to test truncation + // of that part. + #if NET + /// + /// Test the Build() function when all the fields are truncated, and the + /// overall length is still within the max. + /// + [Fact] + public void Build_Truncate_All() + { + var name = + UserAgent.Build( + 192, + // Payload version > 2 chars. + "1234", + // Driver name > 12 chars. + "A01234567890123456789", + // Driver version > 24 chars. + "B012345678901234567890123456789", + // Architecture > 10 chars. + Architecture.LoongArch64, + // OS Type > 10 chars. + "C01234567890123456789", + // OS Info > 44 chars. + "D01234567890123456789012345678901234567890123456789", + // Runtime Info > 44 chars. + "E01234567890123456789012345678901234567890123456789"); + Assert.Equal(152, name.Length); + Assert.Equal( + "12|" + + "A01234567890|" + + "B01234567890123456789012|" + + "LoongArch6|" + + "C012345678|" + + "D0123456789012345678901234567890123456789012|" + + "E0123456789012345678901234567890123456789012", + name); + } + #endif + + /// + /// Test the Clean() function for null input. + /// + [Fact] + public void Clean_Null() + { + // Null becomes "Unknown". + Assert.Equal("Unknown", UserAgent.Clean(null)); + } + + /// + /// Test the Clean() function for empty input. + /// + [Fact] + public void Clean_Empty() + { + // Empty string becomes "Unknown". + Assert.Equal("Unknown", UserAgent.Clean(string.Empty)); + } + + /// + /// Test the Clean() function for whitespace input. + /// + [Fact] + public void Clean_Whitespace() + { + // Whitespace string becomes "Unknown". + Assert.Equal("Unknown", UserAgent.Clean(" ")); + Assert.Equal("Unknown", UserAgent.Clean("\t")); + Assert.Equal("Unknown", UserAgent.Clean("\r")); + Assert.Equal("Unknown", UserAgent.Clean("\n")); + Assert.Equal("Unknown", UserAgent.Clean(" \t\r\n")); + } + + /// + /// Test the Clean() function with leading and trailing whitespace. + /// + [Fact] + public void Clean_Leading_Trailing_Whitespace() + { + // Leading and trailing whitespace are removed. + Assert.Equal("A", UserAgent.Clean(" A")); + Assert.Equal("A", UserAgent.Clean("A\t")); + Assert.Equal("A", UserAgent.Clean("\rA\n")); + } + + /// + /// Test the Clean() function with permitted characters. + /// + [Fact] + public void Clean_Permitted_Characters() + { + // All permitted characters are preserved. + Assert.Equal(AllPermitted, UserAgent.Clean(AllPermitted)); + } + + /// + /// Test the Clean() function with various disallowed characters. + /// + [Fact] + public void Clean_Disallowed_Characters() + { + // Each disallowed character is replaced with underscore. + Assert.Equal( + "A_B_C_D_E_F_G_H_I_J_K_L_M_N_O_P", + UserAgent.Clean("A|B,C;D:E'F\"G[H{I]J}K\\L/MO?P")); + Assert.Equal( + "Q_R_S_T_U_V_W_X+Y-Z_a.b_c_d_e_f_g_h_i_j_k_l_m_n_o", + UserAgent.Clean("Q^R_S`T~U(V)W*X+Y-Z_a.b,c/d:eg'h\"i[j]k{l}m|n\\o")); + } + + /// + /// Test the Clean() function with all Unicode characters. + /// + [Fact] + public void Clean_All_Unicode_Characters() + { + // All disallowed characters are replaced with underscore. + for (char c = (char)0u; /* see condition below */ ; ++c) + { + var clean = UserAgent.Clean(c.ToString()); + + // Whitespace becomes "Unknown". + if (char.IsWhiteSpace(c)) + { + Assert.Equal("Unknown", clean); + } + else if ( + #if NET + AllPermitted.Contains(c) + #else + AllPermitted.Contains(c.ToString()) + #endif + ) + { + Assert.Equal(c.ToString(), clean); + } + else + { + Assert.Equal("_", clean); + } + + // We can't use 'c <= 0xffff' as the terminating condition because + // incrementing a char past 0xffff overflows back to 0x0000, and the + // loop will iterate forever. + // + // Instead, we check for the terminating condition inside the loop + // and break out when we reach it. + // + if (c == 0xffff) + { + break; + } + } + } + + /// + /// Test the Trunc() function. + /// + [Fact] + public void Trunc() + { + // Max length of 0. + Assert.Equal("", UserAgent.Truncate("", 0)); + Assert.Equal("", UserAgent.Truncate(" ", 0)); + Assert.Equal("", UserAgent.Truncate("A", 0)); + Assert.Equal("", UserAgent.Truncate("ABCDE FGHIJ", 0)); + + // Max length of 1. + Assert.Equal("", UserAgent.Truncate("", 1)); + Assert.Equal(" ", UserAgent.Truncate(" ", 1)); + Assert.Equal("A", UserAgent.Truncate("A", 1)); + Assert.Equal("A", UserAgent.Truncate("ABCDE FGHIJ", 1)); + + // Max length of 5. + Assert.Equal("", UserAgent.Truncate("", 5)); + Assert.Equal(" ", UserAgent.Truncate(" ", 5)); + Assert.Equal("A", UserAgent.Truncate("A", 5)); + Assert.Equal("ABCDE", UserAgent.Truncate("ABCDE FGHIJ", 5)); + + // Max length of 100. + Assert.Equal("", UserAgent.Truncate("", 100)); + Assert.Equal(" ", UserAgent.Truncate(" ", 100)); + Assert.Equal("A", UserAgent.Truncate("A", 100)); + Assert.Equal("ABCDE FGHIJ", UserAgent.Truncate("ABCDE FGHIJ", 100)); + } + + #endregion Tests + + #region Private Fields + + // The xUnit output helper. + private readonly ITestOutputHelper _output; + + #endregion Private Fields +} diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs index 633f6edf0c..b57919de7d 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs @@ -348,6 +348,7 @@ public virtual TDSMessageCollection OnLogin7Request(ITDSServerSession session, T } } + // Pass the packet to our delegate if we have one. OnLogin7Validated?.Invoke(loginRequest); // Check if SSPI authentication is requested