diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml new file mode 100644 index 0000000000..a5cad14cf9 --- /dev/null +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlClientApp.xml @@ -0,0 +1,141 @@ + + + + + Specifies the known application identifiers that Microsoft.Data.SqlClient reports for user agent telemetry. + + + + Production applications that meet the bar are welcome to reserve an identifier here. + + + Identifier reservations are as follows: + + + + 0x0001-0x7FFF: Microsoft-defined large-scale applications. + + + 0x8000-0xBFFF: Reserved for small-scale use. + + + 0xC000-0xFFFF: Public and developer use. + + + + An unregistered identifier may still be reported by casting a value to this type. Identifiers are limited to + the 16-bit space the protocol allows, so a value outside 0x0000 to 0xFFFF is rejected when it is assigned to + . + + + + + + No application identity is reported. This is the default. + + + 0 + + + + + The Microsoft Entity Framework Core SQL Server provider. + + + 1 + + + + + Microsoft Semantic Kernel. + + + 2 + + + + + Microsoft SQL Server Management Studio. + + + 3 + + + + + Microsoft SQL Server Management Objects. + + + 4 + + + + + Microsoft SQL Server Data-Tier Application Framework. + + + 5 + + + + + Microsoft SQL Tools Service. + + + 6 + + + + + Microsoft ASP.NET Core distributed SQL Server cache. + + + 7 + + + + + Microsoft Entity Framework 6 SQL Server provider. + + + 8 + + + + + Microsoft Azure Functions SQL extension. + + + 9 + + + + + Microsoft Orleans ADO.NET providers. + + + 10 + + + + + Microsoft Durable Task SQL Server provider. + + + 11 + + + + + The sqlpackage command-line tool. + + + 12 + + + sqlpackage is built on the Data-Tier Application Framework, but reports its own identifier so that + command-line use can be told apart from other callers of that framework. + + + + diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml index 5f88a1498e..7f91f099d4 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml @@ -2294,6 +2294,57 @@ The following sample tries to open a connection to an invalid database to simula + + + Gets or sets the middleware application identity reported to the server for this connection. + + + A value. The default is + . + + + + Set the identity before opening the connection: + + + using Microsoft.Data.SqlClient; + + using var connection = new SqlConnection(connectionString); + connection.SqlClientAppId = SqlClientApp.EntityFramework; + connection.Open(); + + + + The value is outside the range 0 to 65535. + + + The connection is opening or open. The identity is reported during login, so it must be set beforehand. + + + + This API is intended for registered applications that reserve an identifier in + . An unregistered identifier may be reported by casting + a value to that type, provided it is within the 16-bit range the protocol allows. + + + This value is telemetry. It is supplied entirely by the client, which may report any identifier in range, + so it is not an authenticated identity and must not be used for authorization or any other security + decision. + + + The identity is sent once, during login, so it must be set before the connection is opened. + + + When pooling is enabled the value is reported only while establishing a new physical connection, and it is + not part of the pool key. A connection served from the pool therefore reports the identity of whichever + connection caused that physical connection to be created, and physical connections opened in the background + to satisfy Min Pool Size report + . Applications that mix identities over one + connection string should treat this telemetry as indicative rather than exact, or disable pooling where an + exact attribution is required. + + + Gets a string that identifies the database client. diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index 266d0b9a1d..b88678a750 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -592,6 +592,37 @@ public void LogError(string type, string method, string message) { } public void LogInfo(string type, string method, string message) { } } +/// +public enum SqlClientApp +{ + /// + Unknown = 0x0000, + /// + EntityFramework = 0x0001, + /// + SemanticKernel = 0x0002, + /// + ManagementStudio = 0x0003, + /// + SqlManagementObjects = 0x0004, + /// + DataTierApplicationFramework = 0x0005, + /// + SqlToolsService = 0x0006, + /// + AspNetCoreDistributedSqlServerCache = 0x0007, + /// + EntityFramework6 = 0x0008, + /// + AzureFunctionsSqlExtension = 0x0009, + /// + OrleansAdoNet = 0x000A, + /// + DurableTaskSqlServer = 0x000B, + /// + SqlPackage = 0x000C +} + /// public static class SqlClientMetaDataCollectionNames { @@ -1010,6 +1041,8 @@ public SqlConnection() { } public SqlConnection(string connectionString) { } /// public SqlConnection(string connectionString, Microsoft.Data.SqlClient.SqlCredential credential) { } + /// + public Microsoft.Data.SqlClient.SqlClientApp SqlClientAppId { get { throw null; } set { } } /// [System.ComponentModel.BrowsableAttribute(false)] diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs index ec210e407a..5351e48c8c 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs @@ -306,6 +306,12 @@ internal class SqlConnectionInternal : DbConnectionInternal, IDisposable private bool _sessionRecoveryRequested; + /// + /// The middleware application identity of the that caused + /// this physical connection to be created. Reported once, at login. + /// + private readonly SqlClientApp _sqlClientAppId; + private int _threadIdOwningParserLock = -1; // @TODO: Rename to indicate this has to do with routing @@ -344,12 +350,14 @@ internal SqlConnectionInternal( IDbConnectionPool pool = null, Func> accessTokenCallback = null, SspiContextProvider sspiContextProvider = null, - ISqlClientMetrics metrics = null) + ISqlClientMetrics metrics = null, + SqlClientApp sqlClientAppId = SqlClientApp.Unknown) : base(metrics) { Debug.Assert(connectionOptions is not null, "null connectionOptions"); ConnectionOptions = connectionOptions; + _sqlClientAppId = sqlClientAppId; #if DEBUG if (reconnectSessionData != null) @@ -3063,6 +3071,7 @@ private void Login( login.password = ConnectionOptions.Password; login.applicationName = ConnectionOptions.ApplicationName; login.language = _currentLanguage; + login.appId = _sqlClientAppId; if (!login.userInstance) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs new file mode 100644 index 0000000000..55cdea2b9d --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientApp.cs @@ -0,0 +1,38 @@ +// 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. + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +public enum SqlClientApp +{ + /// + Unknown = 0x0000, + /// + EntityFramework = 0x0001, + /// + SemanticKernel = 0x0002, + /// + ManagementStudio = 0x0003, + /// + SqlManagementObjects = 0x0004, + /// + DataTierApplicationFramework = 0x0005, + /// + SqlToolsService = 0x0006, + /// + AspNetCoreDistributedSqlServerCache = 0x0007, + /// + EntityFramework6 = 0x0008, + /// + AzureFunctionsSqlExtension = 0x0009, + /// + OrleansAdoNet = 0x000A, + /// + DurableTaskSqlServer = 0x000B, + /// + SqlPackage = 0x000C +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs new file mode 100644 index 0000000000..6975475c31 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlClientDriverProperties.cs @@ -0,0 +1,75 @@ +// 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; + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +/// Driver-owned feature flags reported in the Driver Properties part of the +/// USERAGENT login feature extension payload. +/// +/// +/// This part is driver-owned, so its meaning is defined entirely by +/// Microsoft.Data.SqlClient and carries no cross-driver contract. Other +/// drivers use the same part for their own purposes. +/// +[Flags] +internal enum SqlClientDriverProperties : ushort +{ + /// No tracked features are enabled. + None = 0x0000, + + /// + /// The connection pool V2 implementation + /// (Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2) is + /// enabled. + /// + ConnectionPoolV2 = 0x0001 +} + +/// +/// Resolves the flags that describe +/// how this process is configured. +/// +internal static class SqlClientDriverPropertiesResolver +{ + /// + /// The flags describing the current process. + /// + /// + /// The flags are sourced from process-wide switches, so this is stable + /// for the life of the process. + /// + internal static SqlClientDriverProperties Current => + Resolve(LocalAppContextSwitches.UseConnectionPoolV2); + + /// + /// Maps the process configuration to the flags that describe it. + /// + /// + /// Whether the connection pool V2 implementation is enabled. + /// + /// + /// The flags describing the supplied configuration. + /// + /// + /// The mapping is kept separate from because the + /// switches it reads are cached for the life of the process, which makes + /// them impractical to vary in a test. + /// + internal static SqlClientDriverProperties Resolve(bool useConnectionPoolV2) + { + SqlClientDriverProperties properties = SqlClientDriverProperties.None; + + if (useConnectionPoolV2) + { + properties |= SqlClientDriverProperties.ConnectionPoolV2; + } + + return properties; + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index b8b693132e..a8a41bdcd6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -75,6 +75,7 @@ public sealed partial class SqlConnection : DbConnection, ICloneable private string _connectionString; private int _connectRetryCount; private string _accessToken; // Access Token to be used for token based authentication + private SqlClientApp _sqlClientAppId = SqlClientApp.Unknown; // middleware application identity reported at login // connection resiliency private object _reconnectLock; @@ -262,6 +263,7 @@ private SqlConnection(SqlConnection connection) _accessToken = connection._accessToken; _accessTokenCallback = connection._accessTokenCallback; + _sqlClientAppId = connection._sqlClientAppId; // CopyFrom retains the source PoolGroup, and therefore the source ConnectionPoolKey. // The provider must be copied along with it, otherwise the clone would authenticate @@ -381,6 +383,32 @@ public static void RegisterColumnEncryptionKeyStoreProviders(IDictionary + public SqlClientApp SqlClientAppId + { + get => _sqlClientAppId; + set + { + // The identity is only reported while logging in, so allowing it + // to change afterwards would let the getter report a value that + // was never sent. + if (!InnerConnection.AllowSetConnectionString) + { + throw ADP.OpenConnectionPropertySet(nameof(SqlClientAppId), InnerConnection.State); + } + + // Identifiers are carried in 16 bits, so anything outside that + // range cannot be reported and is rejected here rather than + // being silently truncated at login. + if ((int)value < 0 || (int)value > ushort.MaxValue) + { + throw SQL.InvalidSqlClientAppId(value, nameof(value)); + } + + _sqlClientAppId = value; + } + } + /// public void RegisterColumnEncryptionKeyStoreProvidersOnConnection(IDictionary customProviders) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs index c3b7bfd13b..4bb1abd3b4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs @@ -712,7 +712,8 @@ protected virtual DbConnectionInternal CreateConnection( redirectedUserInstance: false, applyTransientFaultHandling: applyTransientFaultHandling, sspiContextProvider: key.SspiContextProvider, - metrics: Metrics); + metrics: Metrics, + sqlClientAppId: sqlOwningConnection?.SqlClientAppId ?? SqlClientApp.Unknown); using (sseConnection) { // NOTE: Retrieve here. This user instance name will be @@ -774,7 +775,8 @@ protected virtual DbConnectionInternal CreateConnection( pool, key.AccessTokenCallback, key.SspiContextProvider, - metrics: Metrics); + metrics: Metrics, + sqlClientAppId: sqlOwningConnection?.SqlClientAppId ?? SqlClientApp.Unknown); } private static DbConnectionPoolGroupOptions CreateConnectionPoolGroupOptions(SqlConnectionOptions connectionOptions) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index c9388a42f1..5e0afa47ce 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -1856,6 +1856,15 @@ internal static Exception EmptyProviderName() #endregion Always Encrypted Errors + internal static Exception InvalidSqlClientAppId(SqlClientApp value, string parameterName) + { + return ADP.ArgumentOutOfRange( + StringsHelper.GetString( + Strings.SQL_InvalidSqlClientAppId, + ((int)value).ToString(CultureInfo.InvariantCulture)), + parameterName); + } + // // Merged Provider // 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 d8c904f1e0..7c079cfee0 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -1356,12 +1356,15 @@ internal void TdsLogin( } int feOffset = length; + // Capture the payload once so the length reserved below and the + // bytes written by WriteLoginData can never disagree. + ReadOnlyMemory userAgent = UserAgent.GetUcs2Bytes(rec.appId); // calculate and reserve the required bytes for the featureEx length = ApplyFeatureExData( requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgent.Ucs2Bytes, + userAgent, useFeatureExt, length ); @@ -1380,7 +1383,8 @@ internal void TdsLogin( length, feOffset, clientInterfaceName, - sspiWriter is { } ? sspiWriter.WrittenSpan : ReadOnlySpan.Empty); + sspiWriter is { } ? sspiWriter.WrittenSpan : ReadOnlySpan.Empty, + userAgent); } finally { @@ -9261,7 +9265,8 @@ private void WriteLoginData(SqlLogin rec, int length, int featureExOffset, string clientInterfaceName, - ReadOnlySpan outSSPI) + ReadOnlySpan outSSPI, + ReadOnlyMemory userAgent) { try { @@ -9521,7 +9526,7 @@ private void WriteLoginData(SqlLogin rec, requestedFeatures, recoverySessionData, fedAuthFeatureExtensionData, - UserAgent.Ucs2Bytes, + userAgent, useFeatureExt, length, true diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs index 6bcc3f2d41..80b832f91d 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserHelperClasses.cs @@ -124,6 +124,7 @@ internal sealed class SqlLogin internal bool readOnlyIntent = false; // read-only intent internal SqlCredential credential; // user id and password in SecureString internal SecureString newSecurePassword; + internal SqlClientApp appId = SqlClientApp.Unknown; // middleware application identity } #nullable enable diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs index d8820c3ce9..7d338a1bde 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UserAgent.cs @@ -3,8 +3,11 @@ // See the LICENSE file in the project root for more information. using System; +using System.Diagnostics; +using System.Globalization; using System.Runtime.InteropServices; using System.Text; +using System.Threading; #nullable enable @@ -31,9 +34,15 @@ internal static class UserAgent /// never larger than 256 characters. /// /// - /// The format is pipe ('|') delimited into 7 parts: + /// The format is pipe ('|') delimited into 9 parts: /// - /// 1|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + /// 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info}|{App Id}|{Driver Properties} + /// + /// + /// This is the base value, whose {App Id} part is always + /// 0000. The payload actually sent at login carries the + /// identifier set on the connection; see + /// GetUcs2Bytes. /// /// /// The {Driver Version} part is the version of the driver, @@ -76,6 +85,19 @@ internal static class UserAgent /// Maximum length is 44 characters. /// /// + /// The {App Id} part is the identifier of the application + /// middleware using the driver, serialized as exactly four uppercase + /// hexadecimal characters, zero-padded. It is always present; + /// 0000 means no application identity was reported. Maximum + /// length is 4 characters. + /// + /// + /// The {Driver Properties} part is a driver-owned feature flag + /// value, serialized as exactly four uppercase hexadecimal characters, + /// zero-padded. It is always present. Maximum length is 4 + /// characters. + /// + /// /// Any characters from the sourced values that are not one of the /// following are replaced with underscore ('_'): /// @@ -108,6 +130,78 @@ internal static class UserAgent #region Helpers + /// + /// + /// Returns the UCS-2 encoded payload reporting the given application + /// identifier. + /// + /// + /// When is + /// , is + /// returned, whose App Id part is 0000. + /// + /// + /// + /// The application identifier set on the connection being logged in. + /// + /// The UCS-2 encoded payload bytes. + internal static ReadOnlyMemory GetUcs2Bytes(SqlClientApp app) + { + if (app == SqlClientApp.Unknown) + { + return Ucs2Bytes; + } + + // Most processes report a single application identifier, so a single + // cached entry serves every login. The pair is cached behind one + // reference so readers never observe a torn ReadOnlyMemory. + AppPayload? cached = Volatile.Read(ref s_appPayload); + if (cached is not null && cached.App == app) + { + return cached.Ucs2Bytes; + } + + ReadOnlyMemory bytes = Encoding.Unicode.GetBytes(BuildPayload(app)); + Volatile.Write(ref s_appPayload, new AppPayload(app, bytes)); + + return bytes; + } + + /// + /// Build the payload string from the current runtime environment, + /// reporting the given application identifier. + /// + /// The application identifier to report. + /// The payload string value. + private static string BuildPayload(SqlClientApp app) => + Build( + MaxLenOverall, + PayloadVersion, + DriverName, + ThisAssembly.PackageVersion, + RuntimeInformation.ProcessArchitecture, + s_osType, + RuntimeInformation.OSDescription, + RuntimeInformation.FrameworkDescription, + ToAppId(app), + (ushort)SqlClientDriverPropertiesResolver.Current); + + /// + /// Narrow an application identifier to the 16 bits the payload reports. + /// + /// + /// rejects values outside the + /// 16-bit range, so this conversion is always lossless. + /// + /// The application identifier to narrow. + /// The narrowed application identifier. + private static ushort ToAppId(SqlClientApp app) + { + Debug.Assert((int)app >= 0 && (int)app <= ushort.MaxValue); + + return (ushort)app; + } + /// /// Static construction builds the Client Interface Name. All known /// exceptions are consumed. @@ -139,16 +233,11 @@ static UserAgent() } #endif + // Remember it for agent payloads built later. + s_osType = osType; + // Build it! - Value = Build( - MaxLenOverall, - PayloadVersion, - DriverName, - ThisAssembly.PackageVersion, - RuntimeInformation.ProcessArchitecture, - osType, - RuntimeInformation.OSDescription, - RuntimeInformation.FrameworkDescription); + Value = BuildPayload(SqlClientApp.Unknown); // Convert it to UCS-2 bytes. // @@ -189,6 +278,14 @@ static UserAgent() /// /// The value of the Runtime Info part. /// + /// + /// The value of the App Id part, serialized as four uppercase + /// hexadecimal characters. + /// + /// + /// The value of the Driver Properties part, serialized as four uppercase + /// hexadecimal characters. + /// /// /// The payload string value, never null, never empty, and never longer /// than . @@ -201,7 +298,9 @@ internal static string Build( Architecture arch, string osType, string osInfo, - string runtimeInfo) + string runtimeInfo, + ushort appId = 0, + ushort driverProperties = 0) { string result; @@ -244,6 +343,19 @@ internal static string Build( // Add the Runtime Info, truncating to its max length. name.Append(Truncate(Clean(runtimeInfo), MaxLenRuntimeInfo)); + name.Append('|'); + + // Add the App Id. It is fixed-width hexadecimal, so it can never + // exceed its maximum length and needs no cleaning. + string appIdPart = FormatHex(appId); + Debug.Assert(appIdPart.Length == MaxLenAppId); + name.Append(appIdPart); + name.Append('|'); + + // Add the Driver Properties, on the same terms as the App Id. + string driverPropertiesPart = FormatHex(driverProperties); + Debug.Assert(driverPropertiesPart.Length == MaxLenDriverProperties); + name.Append(driverPropertiesPart); // Remember the name we've built up. result = name.ToString(); @@ -254,7 +366,8 @@ internal static string Build( // value. result = $"{payloadVersion}|{driverName}|{Unknown}|{Unknown}|" + - $"{Unknown}|{Unknown}|{Unknown}"; + $"{Unknown}|{Unknown}|{Unknown}|{FormatHex(appId)}|" + + $"{FormatHex(driverProperties)}"; } // Truncate to our max length if necessary. @@ -371,6 +484,20 @@ internal static string Clean(string? value) } } + /// + /// Format the given value as exactly four uppercase hexadecimal + /// characters, zero-padded. + /// + /// + /// A never needs more than four hexadecimal + /// characters, so the result is always exactly + /// characters and can never be truncated. + /// + /// The value to format. + /// The formatted value. + internal static string FormatHex(ushort value) => + value.ToString("X4", CultureInfo.InvariantCulture); + /// /// Truncate the given value to the given max length, and return the /// result. @@ -398,7 +525,9 @@ internal static string Truncate(string value, ushort maxLength) #region Private Fields // Our payload format version. - private const string PayloadVersion = "1"; + // + // Version 2 adds the App Id and Driver Properties parts. + private const string PayloadVersion = "2"; // Our well-known .NET driver name. private const string DriverName = "MS-MDS"; @@ -414,6 +543,8 @@ internal static string Truncate(string value, ushort maxLength) private const ushort MaxLenOsType = 10; private const ushort MaxLenOsInfo = 44; private const ushort MaxLenRuntimeInfo = 44; + private const ushort MaxLenAppId = 4; + private const ushort MaxLenDriverProperties = 4; // The OS Type values we promise in our API. private const string Windows = "Windows"; @@ -428,5 +559,29 @@ internal static string Truncate(string value, ushort maxLength) // unknown, invalid, or when errors occur. private const string Unknown = "Unknown"; + // The OS Type resolved during static construction, retained so payloads + // built later use the same value as Value. + private static readonly string s_osType; + + // The payload for the most recently requested application identifier, + // built on first use. + private static AppPayload? s_appPayload; + + /// + /// Pairs a payload with the application identifier it was built for. + /// + private sealed class AppPayload + { + internal AppPayload(SqlClientApp app, ReadOnlyMemory ucs2Bytes) + { + App = app; + Ucs2Bytes = ucs2Bytes; + } + + internal SqlClientApp App { get; } + + internal ReadOnlyMemory Ucs2Bytes { get; } + } + #endregion Private Fields } diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index c8f18d38bc..cff74a5ce5 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -2814,6 +2814,15 @@ internal static string SQL_ActiveDirectoryInvalidStateTransition { } } + /// + /// Looks up a localized string similar to The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535.. + /// + internal static string SQL_InvalidSqlClientAppId { + get { + return ResourceManager.GetString("SQL_InvalidSqlClientAppId", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unsupported state: '{0}'.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index 57cbf80016..05e7ed5491 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -2199,6 +2199,9 @@ Cannot transition from state '{0}' to '{1}'. + + The SqlClient application identifier '{0}' is outside the supported range of 0 to 65535. + Unsupported state: '{0}'. diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs index 830f1bb733..7be39757a2 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/SqlCommandCancelTest.cs @@ -329,7 +329,13 @@ private static void CancelAndDisposePreparedCommand(string constr) try { // Generate a query with a large number of results. - using (var command = new SqlCommand("select @P from sys.objects a cross join sys.objects b cross join sys.objects c cross join sys.objects d cross join sys.objects e cross join sys.objects f", connection)) + // The rows come from constant row sets rather than the system + // catalog, so the scan cannot contend with concurrent DDL on + // the shared test database. + const string rows = "(values(0),(1),(2),(3),(4),(5),(6),(7),(8),(9),(10),(11),(12),(13),(14),(15))"; + string sql = $"select @P from {rows} a(n) cross join {rows} b(n) cross join {rows} c(n) " + + $"cross join {rows} d(n) cross join {rows} e(n) cross join {rows} f(n)"; + using (var command = new SqlCommand(sql, connection)) { command.Parameters.Add(new SqlParameter("@P", SqlDbType.Int) { Value = expectedValue }); connection.Open(); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs index 1a48ccd293..161dc6ae13 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionTests.cs @@ -25,6 +25,7 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { + [Collection(SimulatedServerTestCollection.Name)] public class ConnectionTests { [Fact] @@ -1170,9 +1171,10 @@ public void TestConnWithVectorFeatExtVersionNegotiation(bool expectedConnectionR } } - // 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. + /// + /// Verifies that LOGIN7 sends the USERAGENT payload carrying the connection's application + /// identity, regardless of whether the server acknowledges the extension. + /// [Theory] // Allow the server to ack. [InlineData(true)] @@ -1227,6 +1229,7 @@ public void TestConnWithUserAgentFeatureExtension(bool sendAck) }.ConnectionString; using var connection = new SqlConnection(connStr); + connection.SqlClientAppId = SqlClientApp.EntityFramework; connection.Open(); // Verify the connection itself succeeded @@ -1237,9 +1240,38 @@ public void TestConnWithUserAgentFeatureExtension(bool sendAck) Assert.True(firstFeatureIsUserAgent); Assert.True(tokenWasNotNull); Assert.True(dataLengthAtLeast1); - Assert.Equal(UserAgent.Ucs2Bytes.ToArray(), observedPayload); + Assert.Equal(UserAgent.GetUcs2Bytes(SqlClientApp.EntityFramework).ToArray(), observedPayload); // TODO: Confirm the server sent an Ack by reading log message from SqlInternalConnectionTds } + + /// + /// Verifies the application identity cannot be changed once the connection is open, since + /// it is only reported during login and the getter would otherwise report a value that was + /// never sent. + /// + [Fact] + public void SqlClientAppId_CannotBeSet_WhenConnectionIsOpen() + { + using TdsServer server = new(); + server.Start(); + + var connStr = new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false, + }.ConnectionString; + + using var connection = new SqlConnection(connStr); + connection.SqlClientAppId = SqlClientApp.EntityFramework; + connection.Open(); + + Assert.Throws( + () => connection.SqlClientAppId = SqlClientApp.SemanticKernel); + + // The connection still reports the identity it logged in with. + Assert.Equal(SqlClientApp.EntityFramework, connection.SqlClientAppId); + } } } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs new file mode 100644 index 0000000000..fd14c57fba --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlClientAppTests.cs @@ -0,0 +1,172 @@ +// 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 Microsoft.Data.SqlClient; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// +/// Tests for the application identifier registry. +/// +public class SqlClientAppTests +{ + /// + /// Verifies the enum is CLS-compliant, so it is usable from every .NET + /// language. + /// + [Fact] + public void UnderlyingType_Is_Int() + { + Assert.Equal(typeof(int), Enum.GetUnderlyingType(typeof(SqlClientApp))); + } + + /// + /// Verifies the default value reports no application identity. + /// + [Fact] + public void Default_Is_Unknown() + { + Assert.Equal(SqlClientApp.Unknown, default(SqlClientApp)); + Assert.Equal(0, (int)SqlClientApp.Unknown); + } + + /// + /// Verifies the reserved identifiers keep their assigned values, since + /// changing one would silently re-map an application's telemetry. + /// + [Theory] + [InlineData(SqlClientApp.EntityFramework, 0x0001)] + [InlineData(SqlClientApp.SemanticKernel, 0x0002)] + [InlineData(SqlClientApp.ManagementStudio, 0x0003)] + [InlineData(SqlClientApp.SqlManagementObjects, 0x0004)] + [InlineData(SqlClientApp.DataTierApplicationFramework, 0x0005)] + [InlineData(SqlClientApp.SqlToolsService, 0x0006)] + [InlineData(SqlClientApp.AspNetCoreDistributedSqlServerCache, 0x0007)] + [InlineData(SqlClientApp.EntityFramework6, 0x0008)] + [InlineData(SqlClientApp.AzureFunctionsSqlExtension, 0x0009)] + [InlineData(SqlClientApp.OrleansAdoNet, 0x000A)] + [InlineData(SqlClientApp.DurableTaskSqlServer, 0x000B)] + [InlineData(SqlClientApp.SqlPackage, 0x000C)] + public void Members_Have_Stable_Values(SqlClientApp app, int expected) + { + Assert.Equal(expected, (int)app); + } + + /// + /// Verifies an unregistered identifier can be reported by casting, which + /// keeps the API forward compatible with identifiers added later. + /// + [Fact] + public void Unregistered_Identifier_Is_Accepted() + { + SqlClientApp app = (SqlClientApp)0xC001; + + Assert.False(Enum.IsDefined(typeof(SqlClientApp), app)); + + using SqlConnection connection = new(); + connection.SqlClientAppId = app; + + Assert.Equal(app, connection.SqlClientAppId); + } + + /// + /// Verifies the boundaries of the 16-bit identifier space are accepted, + /// since the payload reports the identifier in exactly 16 bits. + /// + [Theory] + [InlineData(0)] + [InlineData(ushort.MaxValue)] + public void Identifier_In_Range_Is_Accepted(int value) + { + using SqlConnection connection = new(); + + connection.SqlClientAppId = (SqlClientApp)value; + + Assert.Equal(value, (int)connection.SqlClientAppId); + } + + /// + /// Verifies an identifier outside the 16-bit space is rejected rather than + /// silently truncated when the payload is built. + /// + [Theory] + [InlineData(-1)] + [InlineData(ushort.MaxValue + 1)] + [InlineData(int.MaxValue)] + [InlineData(int.MinValue)] + public void Identifier_Out_Of_Range_Throws(int value) + { + using SqlConnection connection = new(); + + Assert.Throws( + () => connection.SqlClientAppId = (SqlClientApp)value); + + // The rejected value is not retained. + Assert.Equal(SqlClientApp.Unknown, connection.SqlClientAppId); + } + + /// + /// Verifies the connection reports no application identity until one is + /// assigned, and round-trips the value it is given. + /// + [Fact] + public void SqlConnection_SqlClientAppId_RoundTrips() + { + using SqlConnection connection = new(); + + Assert.Equal(SqlClientApp.Unknown, connection.SqlClientAppId); + + connection.SqlClientAppId = SqlClientApp.SemanticKernel; + + Assert.Equal(SqlClientApp.SemanticKernel, connection.SqlClientAppId); + } + + /// + /// Verifies a cloned connection keeps the application identity of the + /// connection it was cloned from, so cloning does not silently drop the + /// identity back to . + /// + [Fact] + public void Clone_Preserves_SqlClientAppId() + { + using SqlConnection connection = new(); + connection.SqlClientAppId = SqlClientApp.SqlPackage; + + using SqlConnection clone = (SqlConnection)((ICloneable)connection).Clone(); + + Assert.Equal(SqlClientApp.SqlPackage, clone.SqlClientAppId); + } + + /// + /// Verifies the driver properties part reports the connection pool V2 flag + /// when, and only when, that implementation is enabled. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DriverProperties_Reports_ConnectionPoolV2(bool useConnectionPoolV2) + { + SqlClientDriverProperties expected = useConnectionPoolV2 + ? SqlClientDriverProperties.ConnectionPoolV2 + : SqlClientDriverProperties.None; + + Assert.Equal(expected, SqlClientDriverPropertiesResolver.Resolve(useConnectionPoolV2)); + } + + /// + /// Verifies the flags reported for this process agree with the switch they + /// are derived from, so + /// cannot drift from the mapping it delegates to. + /// + [Fact] + public void DriverProperties_Current_Matches_Switch() + { + SqlClientDriverProperties expected = + SqlClientDriverPropertiesResolver.Resolve(LocalAppContextSwitches.UseConnectionPoolV2); + + Assert.Equal(expected, SqlClientDriverPropertiesResolver.Current); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs index 80b826700e..322ed4691e 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/UserAgentTests.cs @@ -72,11 +72,12 @@ public void Value_Runtime_Parts() // // The format should be: // - // 1|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info} + // 2|MS-MDS|{Driver Version}|{Arch}|{OS Type}|{OS Info}|{Runtime Info}| + // {App Id}|{Driver Properties} // var parts = value.Split('|'); - Assert.Equal(7, parts.Length); - Assert.Equal("1", parts[0]); + Assert.Equal(9, parts.Length); + Assert.Equal("2", parts[0]); Assert.Equal("MS-MDS", parts[1]); Assert.Equal(ThisAssembly.PackageVersion, parts[2]); @@ -116,6 +117,14 @@ public void Value_Runtime_Parts() // 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); + + // App Id defaults to Unknown, and is always four hexadecimal + // characters. + Assert.Equal("0000", parts[7]); + + // Driver Properties is always four hexadecimal characters. + Assert.Equal(4, parts[8].Length); + Assert.Matches("^[0-9A-F]{4}$", parts[8]); } /// @@ -149,6 +158,96 @@ public void Ucs2Bytes_Runtime_Parts() Assert.Equal(UserAgent.Value, value); } + /// + /// Test that the default payload is reused when no application identity is + /// set on the connection. + /// + [Fact] + public void GetUcs2Bytes_Unknown_App_Returns_Value() + { + var bytes = UserAgent.GetUcs2Bytes(SqlClientApp.Unknown); + + Assert.Equal(UserAgent.Ucs2Bytes.ToArray(), bytes.ToArray()); + Assert.Equal(9, Decode(bytes).Split('|').Length); + } + + /// + /// Test that an application identity is reported in the App Id part, + /// leaving the other parts unchanged. + /// + [Fact] + public void GetUcs2Bytes_App_Sets_App_Id() + { + string value = Decode(UserAgent.GetUcs2Bytes(SqlClientApp.SemanticKernel)); + + _output.WriteLine($"UserAgent with app: {value}"); + + var parts = value.Split('|'); + Assert.Equal(9, parts.Length); + Assert.Equal("0002", parts[7]); + + // Every other part matches the default payload. + var defaultParts = UserAgent.Value.Split('|'); + for (int i = 0; i < parts.Length; ++i) + { + if (i != 7) + { + Assert.Equal(defaultParts[i], parts[i]); + } + } + } + + /// + /// Test that the payload for an application identity is built once and + /// reused across logins. + /// + [Fact] + public void GetUcs2Bytes_App_Reuses_Payload() + { + Assert.True( + UserAgent.GetUcs2Bytes(SqlClientApp.ManagementStudio).Span.Overlaps( + UserAgent.GetUcs2Bytes(SqlClientApp.ManagementStudio).Span)); + } + + /// + /// Test that the Build() function emits the App Id and Driver Properties as + /// four uppercase hexadecimal characters. + /// + [Theory] + [InlineData((ushort)0, (ushort)0, "2|A|B|X64|C|D|E|0000|0000")] + [InlineData((ushort)7, (ushort)1, "2|A|B|X64|C|D|E|0007|0001")] + [InlineData((ushort)0x00AB, (ushort)0, "2|A|B|X64|C|D|E|00AB|0000")] + [InlineData(ushort.MaxValue, ushort.MaxValue, "2|A|B|X64|C|D|E|FFFF|FFFF")] + public void Build_App_Id_And_Driver_Properties( + ushort appId, + ushort driverProperties, + string expected) + { + Assert.Equal( + expected, + UserAgent.Build( + maxLen: 256, + payloadVersion: "2", + driverName: "A", + driverVersion: "B", + Architecture.X64, + osType: "C", + osInfo: "D", + runtimeInfo: "E", + appId: appId, + driverProperties: driverProperties)); + } + + /// + /// Decode a UCS-2 encoded payload back to its string form. + /// + private static string Decode(ReadOnlyMemory bytes) => + #if NET + Encoding.Unicode.GetString(bytes.Span); + #else + Encoding.Unicode.GetString(bytes.ToArray()); + #endif + /// /// Test the Build() function when it truncates the overall length. /// @@ -171,6 +270,9 @@ public void Ucs2Bytes_Runtime_Parts() [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")] + [InlineData(16, "2|A|B|X64|C|D|E|")] + [InlineData(20, "2|A|B|X64|C|D|E|0000")] + [InlineData(25, "2|A|B|X64|C|D|E|0000|0000")] public void Build_Truncate_Overall(ushort maxLen, string expected) { Assert.Equal( @@ -200,7 +302,7 @@ public void Build_Truncate_Payload_Version() // The payload version is longer than its per-field max length of 2. Assert.Equal( - "12|A|B|X64|C|D|E", + "12|A|B|X64|C|D|E|0000|0000", UserAgent.Build( 128, "1234", "A", "B", Architecture.X64, "C", "D", "E")); } @@ -219,7 +321,7 @@ public void Build_Truncate_Driver_Name() // The driver name is longer than its per-field max length of 12. Assert.Equal( - "2|LongDriverNa|B|X64|C|D|E", + "2|LongDriverNa|B|X64|C|D|E|0000|0000", UserAgent.Build( 128, "2", "LongDriverName", "B", Architecture.X64, "C", "D", "E")); @@ -240,7 +342,7 @@ public void Build_Truncate_Driver_Version() // The driver version is longer than its per-field max length of 24. Assert.Equal( - "2|A|ReallyLongDriverVersionS|X64|C|D|E", + "2|A|ReallyLongDriverVersionS|X64|C|D|E|0000|0000", UserAgent.Build( 128, "2", "A", "ReallyLongDriverVersionString", Architecture.X64, "C", "D", "E")); @@ -264,7 +366,7 @@ public void Build_Truncate_Arch() #if NET // The Architecture is longer than its per-field max length of 10. Assert.Equal( - "2|A|B|LoongArch6|C|D|E", + "2|A|B|LoongArch6|C|D|E|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.LoongArch64, "C", "D", "E")); #endif @@ -284,7 +386,7 @@ public void Build_Truncate_OS_Type() // The OS Type is longer than its per-field max length of 10. Assert.Equal( - "2|A|B|X64|VeryLongOs|D|E", + "2|A|B|X64|VeryLongOs|D|E|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.X64, "VeryLongOsName", "D", "E")); @@ -304,7 +406,7 @@ public void Build_Truncate_OS_Info() // The OS Type is longer than its per-field max length of 44. Assert.Equal( - "2|A|B|X64|C|01234567890123456789012345678901234567890123|E", + "2|A|B|X64|C|01234567890123456789012345678901234567890123|E|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.X64, "C", "01234567890123456789012345678901234567890123456789", @@ -326,7 +428,7 @@ public void Build_Truncate_Runtime_Info() // The Runtime Type is longer than its per-field max length of 44. Assert.Equal( - "2|A|B|X64|C|D|01234567890123456789012345678901234567890123", + "2|A|B|X64|C|D|01234567890123456789012345678901234567890123|0000|0000", UserAgent.Build( 128, "2", "A", "B", Architecture.X64, "C", "D", "01234567890123456789012345678901234567890123456789")); @@ -357,7 +459,7 @@ public void Build_Truncate_Most() "D01234567890123456789012345678901234567890123456789", // Runtime Info > 44 chars. "E01234567890123456789012345678901234567890123456789"); - Assert.Equal(145, name.Length); + Assert.Equal(155, name.Length); Assert.Equal( "12|" + "A01234567890|" + @@ -365,7 +467,8 @@ public void Build_Truncate_Most() "X64|" + "C012345678|" + "D0123456789012345678901234567890123456789012|" + - "E0123456789012345678901234567890123456789012", + "E0123456789012345678901234567890123456789012|" + + "0000|0000", name); } @@ -396,7 +499,7 @@ public void Build_Truncate_All() "D01234567890123456789012345678901234567890123456789", // Runtime Info > 44 chars. "E01234567890123456789012345678901234567890123456789"); - Assert.Equal(152, name.Length); + Assert.Equal(162, name.Length); Assert.Equal( "12|" + "A01234567890|" + @@ -404,7 +507,8 @@ public void Build_Truncate_All() "LoongArch6|" + "C012345678|" + "D0123456789012345678901234567890123456789012|" + - "E0123456789012345678901234567890123456789012", + "E0123456789012345678901234567890123456789012|" + + "0000|0000", name); } #endif