diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index 8f13730f68..f0fd358a82 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -13,6 +13,14 @@ Tests and tools may require different .NET Runtimes that may be installed independently. For example, tests targeting .NET 8.0 will need that runtime installed. +### NuGet CLI + +The `GenerateMdsPackage` build target uses `nuget.exe` to create the Microsoft.Data.SqlClient NuGet +package, and is thus only supported on Windows build hosts. In CI, this is installed automatically +by the `NuGetToolInstaller@1` pipeline task. For local builds, install the [NuGet +CLI](https://learn.microsoft.com/nuget/install-nuget-client-tools) and ensure `nuget.exe` is on your +PATH. + ### Visual Studio This project should be built with Visual Studio 2019+ for the best compatibility. The required set of components are provided in the below file: diff --git a/Directory.Packages.props b/Directory.Packages.props index b1a858bf72..2d150a29a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,20 @@ + + @@ -21,7 +35,7 @@ We never reference this package via its project, so we always need a version specified. --> - + @@ -51,7 +65,7 @@ - + @@ -83,14 +97,15 @@ + - - + + diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj new file mode 100644 index 0000000000..0ea12bd7d2 --- /dev/null +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -0,0 +1,34 @@ + + + + + net481;net10.0-windows + net10.0-windows + + true + WinExe + Microsoft.Data.SqlClient.Samples.AzureSqlConnector + AzureSqlConnector + true + latest + disable + AnyCPU + true + false + + + + + + + + diff --git a/doc/apps/AzureSqlConnector/IdentityQuery.cs b/doc/apps/AzureSqlConnector/IdentityQuery.cs new file mode 100644 index 0000000000..6c6cff84a1 --- /dev/null +++ b/doc/apps/AzureSqlConnector/IdentityQuery.cs @@ -0,0 +1,25 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Shared SQL text used by both (UI-thread variant) and + /// (worker-thread variant). Keeping the literal in one place + /// avoids drift when one variant gains a new column. + /// + internal static class IdentityQuery + { + public const string CommandText = + "SELECT " + + " SUSER_SNAME() AS LoggedInUser, " + + " ORIGINAL_LOGIN() AS OriginalLogin, " + + " USER_NAME() AS DatabaseUser, " + + " SUSER_ID() AS LoginSid, " + + " DB_NAME() AS DatabaseName, " + + " @@SERVERNAME AS ServerName, " + + " HOST_NAME() AS ClientHost, " + + " APP_NAME() AS AppName, " + + " SESSION_USER AS SessionUser, " + + " CURRENT_USER AS CurrentUser, " + + " @@SPID AS SessionId, " + + " @@VERSION AS ServerVersion;"; + } +} diff --git a/doc/apps/AzureSqlConnector/MainForm.Designer.cs b/doc/apps/AzureSqlConnector/MainForm.Designer.cs new file mode 100644 index 0000000000..4dd6c5a047 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.Designer.cs @@ -0,0 +1,394 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + partial class MainForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.lblServer = new System.Windows.Forms.Label(); + this.txtServer = new System.Windows.Forms.TextBox(); + this.lblDatabase = new System.Windows.Forms.Label(); + this.txtDatabase = new System.Windows.Forms.TextBox(); + this.lblAuthentication = new System.Windows.Forms.Label(); + this.cmbAuthentication = new System.Windows.Forms.ComboBox(); + this.lblUserId = new System.Windows.Forms.Label(); + this.txtUserId = new System.Windows.Forms.TextBox(); + this.lblPassword = new System.Windows.Forms.Label(); + this.txtPassword = new System.Windows.Forms.TextBox(); + this.lblEncrypt = new System.Windows.Forms.Label(); + this.cmbEncrypt = new System.Windows.Forms.ComboBox(); + this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); + this.lblTimeout = new System.Windows.Forms.Label(); + this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.lblOpenMode = new System.Windows.Forms.Label(); + this.cmbOpenMode = new System.Windows.Forms.ComboBox(); + this.lblConnectionString = new System.Windows.Forms.Label(); + this.txtConnectionString = new System.Windows.Forms.TextBox(); + this.btnBuild = new System.Windows.Forms.Button(); + this.btnTest = new System.Windows.Forms.Button(); + this.btnCopy = new System.Windows.Forms.Button(); + this.btnClear = new System.Windows.Forms.Button(); + this.btnWhoAmI = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.txtStatus = new System.Windows.Forms.TextBox(); + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).BeginInit(); + this.statusStrip.SuspendLayout(); + this.SuspendLayout(); + // + // lblServer + // + this.lblServer.AutoSize = true; + this.lblServer.Location = new System.Drawing.Point(16, 18); + this.lblServer.Name = "lblServer"; + this.lblServer.Size = new System.Drawing.Size(75, 13); + this.lblServer.TabIndex = 0; + this.lblServer.Text = "&Server name:"; + // + // txtServer + // + this.txtServer.Location = new System.Drawing.Point(150, 15); + this.txtServer.Name = "txtServer"; + this.txtServer.Size = new System.Drawing.Size(400, 20); + this.txtServer.TabIndex = 1; + // + // lblDatabase + // + this.lblDatabase.AutoSize = true; + this.lblDatabase.Location = new System.Drawing.Point(16, 48); + this.lblDatabase.Name = "lblDatabase"; + this.lblDatabase.Size = new System.Drawing.Size(86, 13); + this.lblDatabase.TabIndex = 2; + this.lblDatabase.Text = "&Database name:"; + // + // txtDatabase + // + this.txtDatabase.Location = new System.Drawing.Point(150, 45); + this.txtDatabase.Name = "txtDatabase"; + this.txtDatabase.Size = new System.Drawing.Size(400, 20); + this.txtDatabase.TabIndex = 3; + // + // lblAuthentication + // + this.lblAuthentication.AutoSize = true; + this.lblAuthentication.Location = new System.Drawing.Point(16, 78); + this.lblAuthentication.Name = "lblAuthentication"; + this.lblAuthentication.Size = new System.Drawing.Size(80, 13); + this.lblAuthentication.TabIndex = 4; + this.lblAuthentication.Text = "&Authentication:"; + // + // cmbAuthentication + // + this.cmbAuthentication.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbAuthentication.FormattingEnabled = true; + this.cmbAuthentication.Location = new System.Drawing.Point(150, 75); + this.cmbAuthentication.Name = "cmbAuthentication"; + this.cmbAuthentication.Size = new System.Drawing.Size(400, 21); + this.cmbAuthentication.TabIndex = 5; + this.cmbAuthentication.SelectedIndexChanged += new System.EventHandler(this.cmbAuthentication_SelectedIndexChanged); + // + // lblUserId + // + this.lblUserId.AutoSize = true; + this.lblUserId.Location = new System.Drawing.Point(16, 108); + this.lblUserId.Name = "lblUserId"; + this.lblUserId.Size = new System.Drawing.Size(45, 13); + this.lblUserId.TabIndex = 6; + this.lblUserId.Text = "&User ID:"; + // + // txtUserId + // + this.txtUserId.Location = new System.Drawing.Point(150, 105); + this.txtUserId.Name = "txtUserId"; + this.txtUserId.Size = new System.Drawing.Size(400, 20); + this.txtUserId.TabIndex = 7; + // + // lblPassword + // + this.lblPassword.AutoSize = true; + this.lblPassword.Location = new System.Drawing.Point(16, 138); + this.lblPassword.Name = "lblPassword"; + this.lblPassword.Size = new System.Drawing.Size(56, 13); + this.lblPassword.TabIndex = 8; + this.lblPassword.Text = "&Password:"; + // + // txtPassword + // + this.txtPassword.Location = new System.Drawing.Point(150, 135); + this.txtPassword.Name = "txtPassword"; + this.txtPassword.Size = new System.Drawing.Size(400, 20); + this.txtPassword.TabIndex = 9; + this.txtPassword.UseSystemPasswordChar = true; + // + // lblEncrypt + // + this.lblEncrypt.AutoSize = true; + this.lblEncrypt.Location = new System.Drawing.Point(16, 168); + this.lblEncrypt.Name = "lblEncrypt"; + this.lblEncrypt.Size = new System.Drawing.Size(46, 13); + this.lblEncrypt.TabIndex = 10; + this.lblEncrypt.Text = "&Encrypt:"; + // + // cmbEncrypt + // + this.cmbEncrypt.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbEncrypt.FormattingEnabled = true; + this.cmbEncrypt.Location = new System.Drawing.Point(150, 165); + this.cmbEncrypt.Name = "cmbEncrypt"; + this.cmbEncrypt.Size = new System.Drawing.Size(200, 21); + this.cmbEncrypt.TabIndex = 11; + // + // chkTrustServerCertificate + // + this.chkTrustServerCertificate.AutoSize = true; + this.chkTrustServerCertificate.Location = new System.Drawing.Point(370, 167); + this.chkTrustServerCertificate.Name = "chkTrustServerCertificate"; + this.chkTrustServerCertificate.Size = new System.Drawing.Size(149, 17); + this.chkTrustServerCertificate.TabIndex = 12; + this.chkTrustServerCertificate.Text = "&Trust server certificate"; + this.chkTrustServerCertificate.UseVisualStyleBackColor = true; + // + // lblTimeout + // + this.lblTimeout.AutoSize = true; + this.lblTimeout.Location = new System.Drawing.Point(16, 198); + this.lblTimeout.Name = "lblTimeout"; + this.lblTimeout.Size = new System.Drawing.Size(101, 13); + this.lblTimeout.TabIndex = 13; + this.lblTimeout.Text = "Connect timeout (s):"; + // + // numTimeout + // + this.numTimeout.Location = new System.Drawing.Point(150, 196); + this.numTimeout.Maximum = new decimal(new int[] { 600, 0, 0, 0 }); + this.numTimeout.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTimeout.Name = "numTimeout"; + this.numTimeout.Size = new System.Drawing.Size(80, 20); + this.numTimeout.TabIndex = 14; + this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); + // + // lblOpenMode + // + this.lblOpenMode.AutoSize = true; + this.lblOpenMode.Location = new System.Drawing.Point(260, 198); + this.lblOpenMode.Name = "lblOpenMode"; + this.lblOpenMode.Size = new System.Drawing.Size(67, 13); + this.lblOpenMode.TabIndex = 25; + this.lblOpenMode.Text = "&Open mode:"; + // + // cmbOpenMode + // + this.cmbOpenMode.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbOpenMode.FormattingEnabled = true; + this.cmbOpenMode.Location = new System.Drawing.Point(350, 195); + this.cmbOpenMode.Name = "cmbOpenMode"; + this.cmbOpenMode.Size = new System.Drawing.Size(200, 21); + this.cmbOpenMode.TabIndex = 26; + // + // lblConnectionString + // + this.lblConnectionString.AutoSize = true; + this.lblConnectionString.Location = new System.Drawing.Point(16, 230); + this.lblConnectionString.Name = "lblConnectionString"; + this.lblConnectionString.Size = new System.Drawing.Size(94, 13); + this.lblConnectionString.TabIndex = 15; + this.lblConnectionString.Text = "Connection string:"; + // + // txtConnectionString + // + this.txtConnectionString.Location = new System.Drawing.Point(16, 246); + this.txtConnectionString.Multiline = true; + this.txtConnectionString.Name = "txtConnectionString"; + this.txtConnectionString.ReadOnly = true; + this.txtConnectionString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtConnectionString.Size = new System.Drawing.Size(534, 60); + this.txtConnectionString.TabIndex = 16; + this.txtConnectionString.BackColor = System.Drawing.SystemColors.Info; + // + // btnBuild + // + this.btnBuild.Location = new System.Drawing.Point(16, 316); + this.btnBuild.Name = "btnBuild"; + this.btnBuild.Size = new System.Drawing.Size(140, 26); + this.btnBuild.TabIndex = 17; + this.btnBuild.Text = "&Build Connection String"; + this.btnBuild.UseVisualStyleBackColor = true; + this.btnBuild.Click += new System.EventHandler(this.btnBuild_Click); + // + // btnTest + // + this.btnTest.Location = new System.Drawing.Point(166, 316); + this.btnTest.Name = "btnTest"; + this.btnTest.Size = new System.Drawing.Size(120, 26); + this.btnTest.TabIndex = 18; + this.btnTest.Text = "Te&st Connection"; + this.btnTest.UseVisualStyleBackColor = true; + this.btnTest.Click += new System.EventHandler(this.btnTest_Click); + // + // btnCopy + // + this.btnCopy.Location = new System.Drawing.Point(296, 316); + this.btnCopy.Name = "btnCopy"; + this.btnCopy.Size = new System.Drawing.Size(120, 26); + this.btnCopy.TabIndex = 19; + this.btnCopy.Text = "Cop&y to Clipboard"; + this.btnCopy.UseVisualStyleBackColor = true; + this.btnCopy.Click += new System.EventHandler(this.btnCopy_Click); + // + // btnClear + // + this.btnClear.Location = new System.Drawing.Point(426, 316); + this.btnClear.Name = "btnClear"; + this.btnClear.Size = new System.Drawing.Size(124, 26); + this.btnClear.TabIndex = 20; + this.btnClear.Text = "Cl&ear All"; + this.btnClear.UseVisualStyleBackColor = true; + this.btnClear.Click += new System.EventHandler(this.btnClear_Click); + // + // btnWhoAmI + // + this.btnWhoAmI.Location = new System.Drawing.Point(16, 348); + this.btnWhoAmI.Name = "btnWhoAmI"; + this.btnWhoAmI.Size = new System.Drawing.Size(534, 26); + this.btnWhoAmI.TabIndex = 21; + this.btnWhoAmI.Text = "&Who Am I? (run identity query on the database)"; + this.btnWhoAmI.UseVisualStyleBackColor = true; + this.btnWhoAmI.Click += new System.EventHandler(this.btnWhoAmI_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(16, 386); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Size = new System.Drawing.Size(40, 13); + this.lblStatus.TabIndex = 22; + this.lblStatus.Text = "Result:"; + // + // txtStatus + // + this.txtStatus.Location = new System.Drawing.Point(16, 402); + this.txtStatus.Multiline = true; + this.txtStatus.Name = "txtStatus"; + this.txtStatus.ReadOnly = true; + this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.txtStatus.Size = new System.Drawing.Size(534, 160); + this.txtStatus.TabIndex = 23; + this.txtStatus.WordWrap = false; + this.txtStatus.Font = new System.Drawing.Font("Consolas", 9F); + // + // statusStrip + // + this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.statusLabel}); + this.statusStrip.Location = new System.Drawing.Point(0, 578); + this.statusStrip.Name = "statusStrip"; + this.statusStrip.Size = new System.Drawing.Size(566, 22); + this.statusStrip.TabIndex = 24; + // + // statusLabel + // + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(39, 17); + this.statusLabel.Text = "Ready"; + // + // MainForm + // + this.AcceptButton = this.btnTest; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(566, 600); + this.Controls.Add(this.statusStrip); + this.Controls.Add(this.txtStatus); + this.Controls.Add(this.lblStatus); + this.Controls.Add(this.btnWhoAmI); + this.Controls.Add(this.btnClear); + this.Controls.Add(this.btnCopy); + this.Controls.Add(this.btnTest); + this.Controls.Add(this.btnBuild); + this.Controls.Add(this.txtConnectionString); + this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.cmbOpenMode); + this.Controls.Add(this.lblOpenMode); + this.Controls.Add(this.numTimeout); + this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkTrustServerCertificate); + this.Controls.Add(this.cmbEncrypt); + this.Controls.Add(this.lblEncrypt); + this.Controls.Add(this.txtPassword); + this.Controls.Add(this.lblPassword); + this.Controls.Add(this.txtUserId); + this.Controls.Add(this.lblUserId); + this.Controls.Add(this.cmbAuthentication); + this.Controls.Add(this.lblAuthentication); + this.Controls.Add(this.txtDatabase); + this.Controls.Add(this.lblDatabase); + this.Controls.Add(this.txtServer); + this.Controls.Add(this.lblServer); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Name = "MainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Azure SQL Connector"; + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).EndInit(); + this.statusStrip.ResumeLayout(false); + this.statusStrip.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblServer; + private System.Windows.Forms.TextBox txtServer; + private System.Windows.Forms.Label lblDatabase; + private System.Windows.Forms.TextBox txtDatabase; + private System.Windows.Forms.Label lblAuthentication; + private System.Windows.Forms.ComboBox cmbAuthentication; + private System.Windows.Forms.Label lblUserId; + private System.Windows.Forms.TextBox txtUserId; + private System.Windows.Forms.Label lblPassword; + private System.Windows.Forms.TextBox txtPassword; + private System.Windows.Forms.Label lblEncrypt; + private System.Windows.Forms.ComboBox cmbEncrypt; + private System.Windows.Forms.CheckBox chkTrustServerCertificate; + private System.Windows.Forms.Label lblTimeout; + private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.Label lblOpenMode; + private System.Windows.Forms.ComboBox cmbOpenMode; + private System.Windows.Forms.Label lblConnectionString; + private System.Windows.Forms.TextBox txtConnectionString; + private System.Windows.Forms.Button btnBuild; + private System.Windows.Forms.Button btnTest; + private System.Windows.Forms.Button btnCopy; + private System.Windows.Forms.Button btnClear; + private System.Windows.Forms.Button btnWhoAmI; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.TextBox txtStatus; + private System.Windows.Forms.StatusStrip statusStrip; + private System.Windows.Forms.ToolStripStatusLabel statusLabel; + } +} diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs new file mode 100644 index 0000000000..9cc6d0648c --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -0,0 +1,590 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Data.SqlClient; +using Microsoft.Identity.Client; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// "UI-thread" variant of the connector form. Opens the SQL connection via + /// on the UI thread; the WinForms + /// keeps the message pump alive while + /// the async I/O completes, so the form remains responsive and MSAL.NET's embedded sign-in + /// browser (for ActiveDirectoryInteractive) parents itself correctly. + /// + public partial class MainForm : Form + { + // ────────────────────────────────────────────────────────────────── + #region Construction + + public MainForm() + { + InitializeComponent(); + this.Text = "Azure SQL Connector — UI thread"; + PopulateAuthenticationMethods(); + PopulateEncryptOptions(); + PopulateOpenModes(); + UpdateCredentialFieldsAvailability(); + + // Force the underlying Win32 window to be created NOW (on the UI thread) so we can + // safely hand its HWND to MSAL later. Even in async mode, MSAL.NET may invoke the + // parent-window callback from a worker thread (e.g. when the driver blocks on a + // synchronous Open()), and touching Form.Handle from a non-UI thread throws + // InvalidOperationException ("Cross-thread operation not valid"). + _ownerHwnd = this.Handle; + + RegisterActiveDirectoryProvider(); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Initialization + + private void PopulateAuthenticationMethods() + { + foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) + { + cmbAuthentication.Items.Add(method); + } + + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + } + + private void PopulateEncryptOptions() + { + cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); + cmbEncrypt.Items.Add(EncryptDisplay.Optional); + cmbEncrypt.Items.Add(EncryptDisplay.Strict); + cmbEncrypt.SelectedIndex = 0; + } + + private void PopulateOpenModes() + { + cmbOpenMode.Items.Add(OpenModeDisplay.Async); + cmbOpenMode.Items.Add(OpenModeDisplay.Sync); + cmbOpenMode.SelectedIndex = 0; + } + + /// + /// Registers a single for every + /// Entra ID authentication method and gives it the form's captured HWND as the parent + /// window owner. Both callbacks intentionally use the HWND captured in the constructor + /// () rather than this.Handle, because MSAL.NET can invoke + /// them from a worker thread (e.g. when the driver blocks on a synchronous Open() + /// or when its internal continuations resume off-UI). + /// + private void RegisterActiveDirectoryProvider() + { + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + IntPtr ownerHwnd = _ownerHwnd; + +#if NETFRAMEWORK + // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. + provider.SetIWin32WindowFunc(() => new Win32WindowHandle(ownerHwnd)); +#endif + + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM + // broker consults on Windows. + provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + + // Without this, MSAL's default device-code callback writes the prompt to + // Console.WriteLine, which is invisible in a WinForms host — the connection + // appears to hang while MSAL polls for a code the user never sees. + provider.SetDeviceCodeFlowCallback(DeviceCodeFlowCallback); + + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider); + #pragma warning disable CS0618 // Type or member is obsolete + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, provider); + #pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Device Code Flow callback. MSAL invokes this on a worker thread before it begins + /// polling the token endpoint. We surface the user code three ways so the user always + /// sees it: (1) appended to the log textbox via BeginInvoke (works whenever the UI + /// thread is pumping — async OpenAsync), (2) the verification URL launched in + /// the default browser, and (3) a modal owned by the MSAL worker thread (works even + /// when the UI thread is blocked by a synchronous Open()). MSAL polling waits + /// for the returned Task to complete, so dismissing the dialog also resumes polling. + /// + private Task DeviceCodeFlowCallback(DeviceCodeResult result) + { + string message = result.Message; + string url = result.VerificationUrl; + string code = result.UserCode; + + if (IsHandleCreated) + { + try + { + BeginInvoke((Action)(() => + { + AppendStatus(string.Empty); + AppendStatus("=== Device Code Flow ==="); + AppendStatus(message); + })); + } + catch (InvalidOperationException) + { + // Form is closing or handle was destroyed; fall through to the modal. + } + } + + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Best-effort; the modal below still shows the URL and code. + } + + MessageBox.Show( + "Sign in to complete Device Code Flow:" + Environment.NewLine + Environment.NewLine + + " URL : " + url + Environment.NewLine + + " Code: " + code + Environment.NewLine + Environment.NewLine + + "A browser window has been opened. Enter the code above, complete sign-in," + + Environment.NewLine + "then click OK to resume the connection.", + "Device Code Flow", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return Task.CompletedTask; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Event Handlers + + private void cmbAuthentication_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateCredentialFieldsAvailability(); + } + + private void btnBuild_Click(object sender, EventArgs e) + { + try + { + SqlConnectionStringBuilder builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + SetStatus("Connection string built successfully.", isError: false); + AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } + catch (Exception ex) + { + txtConnectionString.Text = string.Empty; + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private async void btnTest_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + bool useAsync = IsAsyncOpenSelected(); + SetBusy(true, useAsync ? "Testing connection (OpenAsync)..." : "Testing connection (Open)..."); + AppendStatus(string.Empty); + AppendStatus("Testing connectivity to " + builder.DataSource + " (" + + (useAsync ? "OpenAsync" : "sync Open") + ") ..."); + + try + { + string serverVersion; + using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) + { + await OpenConnectionAsync(connection, useAsync).ConfigureAwait(true); + serverVersion = connection.ServerVersion; + } + + SetStatus("Connected successfully.", isError: false); + AppendStatus("Connected successfully! Server version: " + serverVersion); + } + catch (SqlException ex) + { + SetStatus("Connection failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message + "\r\n" + ex.StackTrace); + } + catch (Exception ex) + { + SetStatus("Connection failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message + "\r\n" + ex.StackTrace); + } + finally + { + SetBusy(false, null); + } + } + + private async void btnWhoAmI_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + bool useAsync = IsAsyncOpenSelected(); + SetBusy(true, useAsync + ? "Querying logged-in identity (OpenAsync)..." + : "Querying logged-in identity (Open)..."); + AppendStatus(string.Empty); + AppendStatus("Running identity query against " + builder.DataSource + " (" + + (useAsync ? "OpenAsync" : "sync Open") + ") ..."); + + try + { + // Same UI-thread reasoning as btnTest_Click — keep the message pump alive for any + // ActiveDirectoryInteractive sign-in that may be required. + using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) + { + await OpenConnectionAsync(connection, useAsync).ConfigureAwait(true); + + using (SqlCommand command = connection.CreateCommand()) + { + command.CommandText = IdentityQuery.CommandText; + + using (SqlDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(true)) + { + if (await reader.ReadAsync().ConfigureAwait(true)) + { + AppendStatus("Identity:"); + for (int i = 0; i < reader.FieldCount; i++) + { + string name = reader.GetName(i); + object value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i); + AppendStatus(" " + name.PadRight(16) + ": " + value); + } + SetStatus("Identity query succeeded.", isError: false); + } + else + { + SetStatus("Identity query returned no rows.", isError: true); + AppendStatus("(no rows returned)"); + } + } + } + } + } + catch (SqlException ex) + { + SetStatus("Identity query failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Identity query failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private void btnCopy_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(txtConnectionString.Text)) + { + SetStatus("Nothing to copy. Build the connection string first.", isError: true); + return; + } + + try + { + Clipboard.SetText(BuildConnectionString().ConnectionString); + SetStatus("Connection string copied to clipboard.", isError: false); + } + catch (Exception ex) + { + SetStatus("Failed to copy to clipboard.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private void btnClear_Click(object sender, EventArgs e) + { + txtServer.Clear(); + txtDatabase.Clear(); + txtUserId.Clear(); + txtPassword.Clear(); + txtConnectionString.Clear(); + txtStatus.Clear(); + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + cmbEncrypt.SelectedIndex = 0; + cmbOpenMode.SelectedIndex = 0; + chkTrustServerCertificate.Checked = false; + numTimeout.Value = 30; + SetStatus("Ready", isError: false); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Connection String Construction + + private SqlConnectionStringBuilder BuildConnectionString() + { + string server = (txtServer.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(server)) + { + throw new InvalidOperationException("Server name is required."); + } + + SqlAuthenticationMethod authMethod = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + DataSource = server, + ConnectTimeout = (int)numTimeout.Value, + }; + + string database = (txtDatabase.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(database)) + { + builder.InitialCatalog = database; + } + + if (authMethod != SqlAuthenticationMethod.NotSpecified) + { + builder.Authentication = authMethod; + } + + if (RequiresUserAndPassword(authMethod)) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException( + "User ID is required for " + authMethod + " authentication."); + } + + builder.UserID = userId; + builder.Password = txtPassword.Text ?? string.Empty; + } + else if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + || authMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity + || authMethod == SqlAuthenticationMethod.ActiveDirectoryMSI + || authMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault + || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(userId)) + { + builder.UserID = userId; + } + + if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + && !string.IsNullOrEmpty(txtPassword.Text)) + { + builder.Password = txtPassword.Text; + } + } + + string encryptValue = cmbEncrypt.SelectedItem as string ?? EncryptDisplay.Mandatory; + switch (encryptValue) + { + case EncryptDisplay.Mandatory: + builder.Encrypt = SqlConnectionEncryptOption.Mandatory; + break; + case EncryptDisplay.Optional: + builder.Encrypt = SqlConnectionEncryptOption.Optional; + break; + case EncryptDisplay.Strict: + builder.Encrypt = SqlConnectionEncryptOption.Strict; + break; + } + + builder.TrustServerCertificate = chkTrustServerCertificate.Checked; + + return builder; + } + + private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) + { + switch (method) + { + case SqlAuthenticationMethod.SqlPassword: +#pragma warning disable CS0618 // Type or member is obsolete + case SqlAuthenticationMethod.ActiveDirectoryPassword: +#pragma warning restore CS0618 + return true; + default: + return false; + } + } + + private static string MaskPassword(SqlConnectionStringBuilder builder) + { + if (string.IsNullOrEmpty(builder.Password)) + { + return builder.ConnectionString; + } + + SqlConnectionStringBuilder copy = new SqlConnectionStringBuilder(builder.ConnectionString) + { + Password = "********", + }; + return copy.ConnectionString; + } + + /// + /// Returns when the user picked Async (OpenAsync) in the + /// open-mode selector. Defaults to async if the selector has not been initialized yet. + /// + private bool IsAsyncOpenSelected() + { + return cmbOpenMode.SelectedItem as string != OpenModeDisplay.Sync; + } + + /// + /// Opens on the calling thread using either + /// or the synchronous + /// based on . The method itself is always async-returning so + /// callers can await uniformly; for the sync case it runs Open() inline on + /// the UI thread (which is supported with WAM broker because the broker dialog is hosted + /// by a separate process and does not need this thread's message pump). + /// + private static Task OpenConnectionAsync(SqlConnection connection, bool useAsync) + { + if (useAsync) + { + return connection.OpenAsync(); + } + + connection.Open(); + return Task.CompletedTask; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Helpers + + private void UpdateCredentialFieldsAvailability() + { + if (cmbAuthentication.SelectedItem == null) + { + return; + } + + SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; + bool passwordEnabled = RequiresUserAndPassword(method) + || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + + txtUserId.Enabled = userEnabled; + txtPassword.Enabled = passwordEnabled; + + if (!passwordEnabled) + { + txtPassword.Clear(); + } + } + + private void SetStatus(string text, bool isError) + { + statusLabel.Text = text; + statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; + } + + private void AppendStatus(string line) + { + if (txtStatus.TextLength > 0) + { + txtStatus.AppendText(Environment.NewLine); + } + txtStatus.AppendText(line ?? string.Empty); + } + + private void SetBusy(bool busy, string statusText) + { + btnBuild.Enabled = !busy; + btnTest.Enabled = !busy; + btnCopy.Enabled = !busy; + btnClear.Enabled = !busy; + btnWhoAmI.Enabled = !busy; + cmbOpenMode.Enabled = !busy; + Cursor = busy ? Cursors.WaitCursor : Cursors.Default; + + if (statusText != null) + { + SetStatus(statusText, isError: false); + } + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Nested Types + + private static class EncryptDisplay + { + public const string Mandatory = "Mandatory"; + public const string Optional = "Optional"; + public const string Strict = "Strict"; + } + + private static class OpenModeDisplay + { + public const string Async = "Async (OpenAsync)"; + public const string Sync = "Sync (Open)"; + } + +#if NETFRAMEWORK + // Tiny IWin32Window wrapper around a raw HWND captured on the UI thread so MSAL.NET's + // legacy IWin32WindowFunc callback can safely return a window owner from a worker thread + // without ever touching Control.Handle off-UI. + private sealed class Win32WindowHandle : IWin32Window + { + private readonly IntPtr _hwnd; + public Win32WindowHandle(IntPtr hwnd) => _hwnd = hwnd; + public IntPtr Handle => _hwnd; + } +#endif + + #endregion + + // ─────────────────────────────────────────────────────────────── + #region Private Fields + + // The form's Win32 window handle, captured on the UI thread in the constructor. + // Read from worker threads by the Entra ID provider callbacks to parent MSAL's sign-in + // / WAM broker UI without illegally touching Control.Handle. + private readonly IntPtr _ownerHwnd; + + #endregion + } +} diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs new file mode 100644 index 0000000000..c872ee38ab --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs @@ -0,0 +1,383 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + partial class MainFormWorker + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.lblServer = new System.Windows.Forms.Label(); + this.txtServer = new System.Windows.Forms.TextBox(); + this.lblDatabase = new System.Windows.Forms.Label(); + this.txtDatabase = new System.Windows.Forms.TextBox(); + this.lblAuthentication = new System.Windows.Forms.Label(); + this.cmbAuthentication = new System.Windows.Forms.ComboBox(); + this.lblUserId = new System.Windows.Forms.Label(); + this.txtUserId = new System.Windows.Forms.TextBox(); + this.lblPassword = new System.Windows.Forms.Label(); + this.txtPassword = new System.Windows.Forms.TextBox(); + this.lblEncrypt = new System.Windows.Forms.Label(); + this.cmbEncrypt = new System.Windows.Forms.ComboBox(); + this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); + this.lblTimeout = new System.Windows.Forms.Label(); + this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.chkClearTokenCache = new System.Windows.Forms.CheckBox(); + this.lblConnectionString = new System.Windows.Forms.Label(); + this.txtConnectionString = new System.Windows.Forms.TextBox(); + this.btnBuild = new System.Windows.Forms.Button(); + this.btnTest = new System.Windows.Forms.Button(); + this.btnCopy = new System.Windows.Forms.Button(); + this.btnClear = new System.Windows.Forms.Button(); + this.btnWhoAmI = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.txtStatus = new System.Windows.Forms.TextBox(); + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).BeginInit(); + this.statusStrip.SuspendLayout(); + this.SuspendLayout(); + // + // lblServer + // + this.lblServer.AutoSize = true; + this.lblServer.Location = new System.Drawing.Point(16, 18); + this.lblServer.Name = "lblServer"; + this.lblServer.Size = new System.Drawing.Size(75, 13); + this.lblServer.TabIndex = 0; + this.lblServer.Text = "&Server name:"; + // + // txtServer + // + this.txtServer.Location = new System.Drawing.Point(150, 15); + this.txtServer.Name = "txtServer"; + this.txtServer.Size = new System.Drawing.Size(400, 20); + this.txtServer.TabIndex = 1; + // + // lblDatabase + // + this.lblDatabase.AutoSize = true; + this.lblDatabase.Location = new System.Drawing.Point(16, 48); + this.lblDatabase.Name = "lblDatabase"; + this.lblDatabase.Size = new System.Drawing.Size(86, 13); + this.lblDatabase.TabIndex = 2; + this.lblDatabase.Text = "&Database name:"; + // + // txtDatabase + // + this.txtDatabase.Location = new System.Drawing.Point(150, 45); + this.txtDatabase.Name = "txtDatabase"; + this.txtDatabase.Size = new System.Drawing.Size(400, 20); + this.txtDatabase.TabIndex = 3; + // + // lblAuthentication + // + this.lblAuthentication.AutoSize = true; + this.lblAuthentication.Location = new System.Drawing.Point(16, 78); + this.lblAuthentication.Name = "lblAuthentication"; + this.lblAuthentication.Size = new System.Drawing.Size(80, 13); + this.lblAuthentication.TabIndex = 4; + this.lblAuthentication.Text = "&Authentication:"; + // + // cmbAuthentication + // + this.cmbAuthentication.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbAuthentication.FormattingEnabled = true; + this.cmbAuthentication.Location = new System.Drawing.Point(150, 75); + this.cmbAuthentication.Name = "cmbAuthentication"; + this.cmbAuthentication.Size = new System.Drawing.Size(400, 21); + this.cmbAuthentication.TabIndex = 5; + this.cmbAuthentication.SelectedIndexChanged += new System.EventHandler(this.cmbAuthentication_SelectedIndexChanged); + // + // lblUserId + // + this.lblUserId.AutoSize = true; + this.lblUserId.Location = new System.Drawing.Point(16, 108); + this.lblUserId.Name = "lblUserId"; + this.lblUserId.Size = new System.Drawing.Size(45, 13); + this.lblUserId.TabIndex = 6; + this.lblUserId.Text = "&User ID:"; + // + // txtUserId + // + this.txtUserId.Location = new System.Drawing.Point(150, 105); + this.txtUserId.Name = "txtUserId"; + this.txtUserId.Size = new System.Drawing.Size(400, 20); + this.txtUserId.TabIndex = 7; + // + // lblPassword + // + this.lblPassword.AutoSize = true; + this.lblPassword.Location = new System.Drawing.Point(16, 138); + this.lblPassword.Name = "lblPassword"; + this.lblPassword.Size = new System.Drawing.Size(56, 13); + this.lblPassword.TabIndex = 8; + this.lblPassword.Text = "&Password:"; + // + // txtPassword + // + this.txtPassword.Location = new System.Drawing.Point(150, 135); + this.txtPassword.Name = "txtPassword"; + this.txtPassword.Size = new System.Drawing.Size(400, 20); + this.txtPassword.TabIndex = 9; + this.txtPassword.UseSystemPasswordChar = true; + // + // lblEncrypt + // + this.lblEncrypt.AutoSize = true; + this.lblEncrypt.Location = new System.Drawing.Point(16, 168); + this.lblEncrypt.Name = "lblEncrypt"; + this.lblEncrypt.Size = new System.Drawing.Size(46, 13); + this.lblEncrypt.TabIndex = 10; + this.lblEncrypt.Text = "&Encrypt:"; + // + // cmbEncrypt + // + this.cmbEncrypt.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbEncrypt.FormattingEnabled = true; + this.cmbEncrypt.Location = new System.Drawing.Point(150, 165); + this.cmbEncrypt.Name = "cmbEncrypt"; + this.cmbEncrypt.Size = new System.Drawing.Size(200, 21); + this.cmbEncrypt.TabIndex = 11; + // + // chkTrustServerCertificate + // + this.chkTrustServerCertificate.AutoSize = true; + this.chkTrustServerCertificate.Location = new System.Drawing.Point(370, 167); + this.chkTrustServerCertificate.Name = "chkTrustServerCertificate"; + this.chkTrustServerCertificate.Size = new System.Drawing.Size(149, 17); + this.chkTrustServerCertificate.TabIndex = 12; + this.chkTrustServerCertificate.Text = "&Trust server certificate"; + this.chkTrustServerCertificate.UseVisualStyleBackColor = true; + // + // lblTimeout + // + this.lblTimeout.AutoSize = true; + this.lblTimeout.Location = new System.Drawing.Point(16, 198); + this.lblTimeout.Name = "lblTimeout"; + this.lblTimeout.Size = new System.Drawing.Size(101, 13); + this.lblTimeout.TabIndex = 13; + this.lblTimeout.Text = "Connect timeout (s):"; + // + // numTimeout + // + this.numTimeout.Location = new System.Drawing.Point(150, 196); + this.numTimeout.Maximum = new decimal(new int[] { 600, 0, 0, 0 }); + this.numTimeout.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTimeout.Name = "numTimeout"; + this.numTimeout.Size = new System.Drawing.Size(80, 20); + this.numTimeout.TabIndex = 14; + this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); + // + // chkClearTokenCache + // + this.chkClearTokenCache.AutoSize = true; + this.chkClearTokenCache.Location = new System.Drawing.Point(260, 198); + this.chkClearTokenCache.Name = "chkClearTokenCache"; + this.chkClearTokenCache.Size = new System.Drawing.Size(290, 17); + this.chkClearTokenCache.TabIndex = 15; + this.chkClearTokenCache.Text = "Clear MSAL token &cache before connect (force prompt)"; + this.chkClearTokenCache.UseVisualStyleBackColor = true; + // + // lblConnectionString + // + this.lblConnectionString.AutoSize = true; + this.lblConnectionString.Location = new System.Drawing.Point(16, 230); + this.lblConnectionString.Name = "lblConnectionString"; + this.lblConnectionString.Size = new System.Drawing.Size(94, 13); + this.lblConnectionString.TabIndex = 15; + this.lblConnectionString.Text = "Connection string:"; + // + // txtConnectionString + // + this.txtConnectionString.Location = new System.Drawing.Point(16, 246); + this.txtConnectionString.Multiline = true; + this.txtConnectionString.Name = "txtConnectionString"; + this.txtConnectionString.ReadOnly = true; + this.txtConnectionString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtConnectionString.Size = new System.Drawing.Size(534, 60); + this.txtConnectionString.TabIndex = 16; + this.txtConnectionString.BackColor = System.Drawing.SystemColors.Info; + // + // btnBuild + // + this.btnBuild.Location = new System.Drawing.Point(16, 316); + this.btnBuild.Name = "btnBuild"; + this.btnBuild.Size = new System.Drawing.Size(140, 26); + this.btnBuild.TabIndex = 17; + this.btnBuild.Text = "&Build Connection String"; + this.btnBuild.UseVisualStyleBackColor = true; + this.btnBuild.Click += new System.EventHandler(this.btnBuild_Click); + // + // btnTest + // + this.btnTest.Location = new System.Drawing.Point(166, 316); + this.btnTest.Name = "btnTest"; + this.btnTest.Size = new System.Drawing.Size(120, 26); + this.btnTest.TabIndex = 18; + this.btnTest.Text = "Te&st Connection"; + this.btnTest.UseVisualStyleBackColor = true; + this.btnTest.Click += new System.EventHandler(this.btnTest_Click); + // + // btnCopy + // + this.btnCopy.Location = new System.Drawing.Point(296, 316); + this.btnCopy.Name = "btnCopy"; + this.btnCopy.Size = new System.Drawing.Size(120, 26); + this.btnCopy.TabIndex = 19; + this.btnCopy.Text = "Cop&y to Clipboard"; + this.btnCopy.UseVisualStyleBackColor = true; + this.btnCopy.Click += new System.EventHandler(this.btnCopy_Click); + // + // btnClear + // + this.btnClear.Location = new System.Drawing.Point(426, 316); + this.btnClear.Name = "btnClear"; + this.btnClear.Size = new System.Drawing.Size(124, 26); + this.btnClear.TabIndex = 20; + this.btnClear.Text = "Cl&ear All"; + this.btnClear.UseVisualStyleBackColor = true; + this.btnClear.Click += new System.EventHandler(this.btnClear_Click); + // + // btnWhoAmI + // + this.btnWhoAmI.Location = new System.Drawing.Point(16, 348); + this.btnWhoAmI.Name = "btnWhoAmI"; + this.btnWhoAmI.Size = new System.Drawing.Size(534, 26); + this.btnWhoAmI.TabIndex = 21; + this.btnWhoAmI.Text = "&Who Am I? (run identity query on the database)"; + this.btnWhoAmI.UseVisualStyleBackColor = true; + this.btnWhoAmI.Click += new System.EventHandler(this.btnWhoAmI_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(16, 386); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Size = new System.Drawing.Size(40, 13); + this.lblStatus.TabIndex = 22; + this.lblStatus.Text = "Result:"; + // + // txtStatus + // + this.txtStatus.Location = new System.Drawing.Point(16, 402); + this.txtStatus.Multiline = true; + this.txtStatus.Name = "txtStatus"; + this.txtStatus.ReadOnly = true; + this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.txtStatus.Size = new System.Drawing.Size(534, 160); + this.txtStatus.TabIndex = 23; + this.txtStatus.WordWrap = false; + this.txtStatus.Font = new System.Drawing.Font("Consolas", 9F); + // + // statusStrip + // + this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.statusLabel}); + this.statusStrip.Location = new System.Drawing.Point(0, 578); + this.statusStrip.Name = "statusStrip"; + this.statusStrip.Size = new System.Drawing.Size(566, 22); + this.statusStrip.TabIndex = 24; + // + // statusLabel + // + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(39, 17); + this.statusLabel.Text = "Ready"; + // + // MainForm + // + this.AcceptButton = this.btnTest; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(566, 600); + this.Controls.Add(this.statusStrip); + this.Controls.Add(this.txtStatus); + this.Controls.Add(this.lblStatus); + this.Controls.Add(this.btnWhoAmI); + this.Controls.Add(this.btnClear); + this.Controls.Add(this.btnCopy); + this.Controls.Add(this.btnTest); + this.Controls.Add(this.btnBuild); + this.Controls.Add(this.txtConnectionString); + this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.numTimeout); + this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkClearTokenCache); + this.Controls.Add(this.chkTrustServerCertificate); + this.Controls.Add(this.cmbEncrypt); + this.Controls.Add(this.lblEncrypt); + this.Controls.Add(this.txtPassword); + this.Controls.Add(this.lblPassword); + this.Controls.Add(this.txtUserId); + this.Controls.Add(this.lblUserId); + this.Controls.Add(this.cmbAuthentication); + this.Controls.Add(this.lblAuthentication); + this.Controls.Add(this.txtDatabase); + this.Controls.Add(this.lblDatabase); + this.Controls.Add(this.txtServer); + this.Controls.Add(this.lblServer); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Name = "MainFormWorker"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Azure SQL Connector — Worker thread (Task.Run + Open)"; + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).EndInit(); + this.statusStrip.ResumeLayout(false); + this.statusStrip.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblServer; + private System.Windows.Forms.TextBox txtServer; + private System.Windows.Forms.Label lblDatabase; + private System.Windows.Forms.TextBox txtDatabase; + private System.Windows.Forms.Label lblAuthentication; + private System.Windows.Forms.ComboBox cmbAuthentication; + private System.Windows.Forms.Label lblUserId; + private System.Windows.Forms.TextBox txtUserId; + private System.Windows.Forms.Label lblPassword; + private System.Windows.Forms.TextBox txtPassword; + private System.Windows.Forms.Label lblEncrypt; + private System.Windows.Forms.ComboBox cmbEncrypt; + private System.Windows.Forms.CheckBox chkTrustServerCertificate; + private System.Windows.Forms.Label lblTimeout; + private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.CheckBox chkClearTokenCache; + private System.Windows.Forms.Label lblConnectionString; + private System.Windows.Forms.TextBox txtConnectionString; + private System.Windows.Forms.Button btnBuild; + private System.Windows.Forms.Button btnTest; + private System.Windows.Forms.Button btnCopy; + private System.Windows.Forms.Button btnClear; + private System.Windows.Forms.Button btnWhoAmI; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.TextBox txtStatus; + private System.Windows.Forms.StatusStrip statusStrip; + private System.Windows.Forms.ToolStripStatusLabel statusLabel; + } +} diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.cs b/doc/apps/AzureSqlConnector/MainFormWorker.cs new file mode 100644 index 0000000000..8d344cf6c4 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.cs @@ -0,0 +1,598 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Data.SqlClient; +using Microsoft.Identity.Client; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// "Worker thread" variant of the connector form. Opens the SQL connection synchronously + /// inside a call so the UI thread never blocks. + /// + /// + /// + /// The form's HWND is captured on the UI thread in the constructor and stashed in + /// . Both Entra ID parent-window callbacks return that captured + /// handle, so they are safe to invoke from the worker thread (touching Form.Handle + /// from a non-UI thread is illegal). + /// + /// + /// Compare with , which keeps Open on the UI thread and relies on + /// for responsiveness. + /// + /// + public partial class MainFormWorker : Form + { + // ────────────────────────────────────────────────────────────────── + #region Construction + + public MainFormWorker() + { + InitializeComponent(); + PopulateAuthenticationMethods(); + PopulateEncryptOptions(); + UpdateCredentialFieldsAvailability(); + + // Force the underlying Win32 window to be created NOW (on the UI thread) so we can + // safely capture its HWND for MSAL to use later from a worker thread. Touching + // Form.Handle from a non-UI thread is illegal, so we read it here once and stash it. + _ownerHwnd = this.Handle; + + RegisterActiveDirectoryProvider(); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Initialization + + private void PopulateAuthenticationMethods() + { + foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) + { + cmbAuthentication.Items.Add(method); + } + + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + } + + private void PopulateEncryptOptions() + { + cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); + cmbEncrypt.Items.Add(EncryptDisplay.Optional); + cmbEncrypt.Items.Add(EncryptDisplay.Strict); + cmbEncrypt.SelectedIndex = 0; + } + + /// + /// Registers a single for every + /// Entra ID authentication method and gives it the form's captured HWND as the parent + /// window owner. Both callbacks intentionally use the HWND captured in the constructor + /// () rather than this.Handle; they are invoked by MSAL on + /// the worker thread that called . + /// + private void RegisterActiveDirectoryProvider() + { + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + IntPtr ownerHwnd = _ownerHwnd; + +#if NETFRAMEWORK + // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. + provider.SetIWin32WindowFunc(() => new Win32WindowHandle(ownerHwnd)); +#endif + + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM + // broker consults on Windows. + provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + + // Without this, MSAL's default device-code callback writes the prompt to + // Console.WriteLine, which is invisible in a WinForms host — the connection + // appears to hang while MSAL polls for a code the user never sees. + provider.SetDeviceCodeFlowCallback(DeviceCodeFlowCallback); + + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider); + #pragma warning disable CS0618 // Type or member is obsolete + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, provider); + #pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Device Code Flow callback. MSAL invokes this on a worker thread before it begins + /// polling the token endpoint. We surface the user code three ways so the user always + /// sees it: (1) appended to the log textbox via BeginInvoke (the UI thread is free in + /// this variant because Open() runs on a Task.Run worker), (2) the verification URL + /// launched in the default browser, and (3) a modal owned by the MSAL worker thread. + /// MSAL polling waits for the returned Task to complete, so dismissing the dialog + /// also resumes polling. + /// + private Task DeviceCodeFlowCallback(DeviceCodeResult result) + { + string message = result.Message; + string url = result.VerificationUrl; + string code = result.UserCode; + + if (IsHandleCreated) + { + try + { + BeginInvoke((Action)(() => + { + AppendStatus(string.Empty); + AppendStatus("=== Device Code Flow ==="); + AppendStatus(message); + })); + } + catch (InvalidOperationException) + { + // Form is closing or handle was destroyed; fall through to the modal. + } + } + + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Best-effort; the modal below still shows the URL and code. + } + + MessageBox.Show( + "Sign in to complete Device Code Flow:" + Environment.NewLine + Environment.NewLine + + " URL : " + url + Environment.NewLine + + " Code: " + code + Environment.NewLine + Environment.NewLine + + "A browser window has been opened. Enter the code above, complete sign-in," + + Environment.NewLine + "then click OK to resume the connection.", + "Device Code Flow", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return Task.CompletedTask; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Event Handlers + + private void cmbAuthentication_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateCredentialFieldsAvailability(); + } + + private void btnBuild_Click(object sender, EventArgs e) + { + try + { + SqlConnectionStringBuilder builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + SetStatus("Connection string built successfully.", isError: false); + AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } + catch (Exception ex) + { + txtConnectionString.Text = string.Empty; + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private async void btnTest_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Testing connection..."); + AppendStatus(string.Empty); + AppendStatus("Testing connectivity to " + builder.DataSource + " ..."); + + MaybeClearTokenCache(); + + try + { + // Run Open() on a thread-pool worker so the UI thread never blocks. The await + // continuation hops back onto the UI thread automatically (the awaiter captures + // the current SynchronizationContext), so it is safe to touch the form's controls + // after the await. + // + // The Entra ID interactive / WAM flows still find a parent window because we + // captured the form's HWND on the UI thread in the constructor and the callbacks + // registered in RegisterActiveDirectoryProvider return that captured handle (no + // UI-thread-only Form.Handle access from the worker thread). + string connectionString = builder.ConnectionString; + string serverVersion = await Task.Run(() => + { + using (SqlConnection connection = new SqlConnection(connectionString)) + { + connection.Open(); + return connection.ServerVersion; + } + }).ConfigureAwait(true); + + SetStatus("Connected successfully.", isError: false); + AppendStatus("Connected successfully! Server version: " + serverVersion); + } + catch (SqlException ex) + { + SetStatus("Connection failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Connection failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private async void btnWhoAmI_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Querying logged-in identity..."); + AppendStatus(string.Empty); + AppendStatus("Running identity query against " + builder.DataSource + " ..."); + + MaybeClearTokenCache(); + + try + { + // Run the whole open + query + read on a worker thread so the UI never blocks. + // We materialize the single result row into a List<(name, value)> on the worker + // and then format it on the UI thread once the await returns. + string connectionString = builder.ConnectionString; + List<(string Name, object Value)> row = await Task.Run(() => + { + using (SqlConnection connection = new SqlConnection(connectionString)) + { + connection.Open(); + + using (SqlCommand command = connection.CreateCommand()) + { + command.CommandText = IdentityQuery.CommandText; + + using (SqlDataReader reader = command.ExecuteReader()) + { + if (!reader.Read()) + { + return null; + } + + var fields = new List<(string, object)>(reader.FieldCount); + for (int i = 0; i < reader.FieldCount; i++) + { + object value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i); + fields.Add((reader.GetName(i), value)); + } + return fields; + } + } + } + }).ConfigureAwait(true); + + if (row is null) + { + SetStatus("Identity query returned no rows.", isError: true); + AppendStatus("(no rows returned)"); + } + else + { + AppendStatus("Identity:"); + foreach (var (name, value) in row) + { + AppendStatus(" " + name.PadRight(16) + ": " + value); + } + SetStatus("Identity query succeeded.", isError: false); + } + } + catch (SqlException ex) + { + SetStatus("Identity query failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Identity query failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private void btnCopy_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(txtConnectionString.Text)) + { + SetStatus("Nothing to copy. Build the connection string first.", isError: true); + return; + } + + try + { + Clipboard.SetText(BuildConnectionString().ConnectionString); + SetStatus("Connection string copied to clipboard.", isError: false); + } + catch (Exception ex) + { + SetStatus("Failed to copy to clipboard.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private void btnClear_Click(object sender, EventArgs e) + { + txtServer.Clear(); + txtDatabase.Clear(); + txtUserId.Clear(); + txtPassword.Clear(); + txtConnectionString.Clear(); + txtStatus.Clear(); + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + cmbEncrypt.SelectedIndex = 0; + chkTrustServerCertificate.Checked = false; + numTimeout.Value = 30; + SetStatus("Ready", isError: false); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Connection String Construction + + private SqlConnectionStringBuilder BuildConnectionString() + { + string server = (txtServer.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(server)) + { + throw new InvalidOperationException("Server name is required."); + } + + SqlAuthenticationMethod authMethod = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + DataSource = server, + ConnectTimeout = (int)numTimeout.Value, + }; + + string database = (txtDatabase.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(database)) + { + builder.InitialCatalog = database; + } + + if (authMethod != SqlAuthenticationMethod.NotSpecified) + { + builder.Authentication = authMethod; + } + + if (RequiresUserAndPassword(authMethod)) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException( + "User ID is required for " + authMethod + " authentication."); + } + + builder.UserID = userId; + builder.Password = txtPassword.Text ?? string.Empty; + } + else if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + || authMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity + || authMethod == SqlAuthenticationMethod.ActiveDirectoryMSI + || authMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault + || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(userId)) + { + builder.UserID = userId; + } + + if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + && !string.IsNullOrEmpty(txtPassword.Text)) + { + builder.Password = txtPassword.Text; + } + } + + string encryptValue = cmbEncrypt.SelectedItem as string ?? EncryptDisplay.Mandatory; + switch (encryptValue) + { + case EncryptDisplay.Mandatory: + builder.Encrypt = SqlConnectionEncryptOption.Mandatory; + break; + case EncryptDisplay.Optional: + builder.Encrypt = SqlConnectionEncryptOption.Optional; + break; + case EncryptDisplay.Strict: + builder.Encrypt = SqlConnectionEncryptOption.Strict; + break; + } + + builder.TrustServerCertificate = chkTrustServerCertificate.Checked; + + return builder; + } + + private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) + { + switch (method) + { + case SqlAuthenticationMethod.SqlPassword: +#pragma warning disable CS0618 // Type or member is obsolete + case SqlAuthenticationMethod.ActiveDirectoryPassword: +#pragma warning restore CS0618 + return true; + default: + return false; + } + } + + private static string MaskPassword(SqlConnectionStringBuilder builder) + { + if (string.IsNullOrEmpty(builder.Password)) + { + return builder.ConnectionString; + } + + SqlConnectionStringBuilder copy = new SqlConnectionStringBuilder(builder.ConnectionString) + { + Password = "********", + }; + return copy.ConnectionString; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Helpers + + private void UpdateCredentialFieldsAvailability() + { + if (cmbAuthentication.SelectedItem == null) + { + return; + } + + SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; + bool passwordEnabled = RequiresUserAndPassword(method) + || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + + txtUserId.Enabled = userEnabled; + txtPassword.Enabled = passwordEnabled; + + if (!passwordEnabled) + { + txtPassword.Clear(); + } + } + + private void SetStatus(string text, bool isError) + { + statusLabel.Text = text; + statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; + } + + private void AppendStatus(string line) + { + if (txtStatus.TextLength > 0) + { + txtStatus.AppendText(Environment.NewLine); + } + txtStatus.AppendText(line ?? string.Empty); + } + + private void SetBusy(bool busy, string statusText) + { + btnBuild.Enabled = !busy; + btnTest.Enabled = !busy; + btnCopy.Enabled = !busy; + btnClear.Enabled = !busy; + btnWhoAmI.Enabled = !busy; + Cursor = busy ? Cursors.WaitCursor : Cursors.Default; + + if (statusText != null) + { + SetStatus(statusText, isError: false); + } + } + + // Only drops the in-process PCA / TokenCredential maps; MSAL's persistent on-disk cache + // and WAM broker accounts are untouched. Sufficient to demo a worker-thread interactive + // prompt when the persistent cache has already been cleared (fresh run or no WAM account + // bound), and a useful reset between back-to-back connects within a single session. + private void MaybeClearTokenCache() + { + if (!chkClearTokenCache.Checked) + { + return; + } + + ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); + AppendStatus("Cleared in-process MSAL token cache."); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Nested Types + + private static class EncryptDisplay + { + public const string Mandatory = "Mandatory"; + public const string Optional = "Optional"; + public const string Strict = "Strict"; + } + + /// + /// Tiny wrapper around a raw HWND captured on the UI thread. + /// Used so that MSAL.NET's IWin32WindowFunc callback can safely return a window + /// owner from a worker thread without ever touching off-UI. + /// Only needed on .NET Framework where the legacy SetIWin32WindowFunc API is used. + /// +#if NETFRAMEWORK + private sealed class Win32WindowHandle : IWin32Window + { + private readonly IntPtr _hwnd; + public Win32WindowHandle(IntPtr hwnd) => _hwnd = hwnd; + public IntPtr Handle => _hwnd; + } +#endif + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Private Fields + + /// + /// The form's Win32 window handle, captured on the UI thread in the constructor. + /// Read from worker threads by the Entra ID provider callbacks to parent MSAL's + /// sign-in / WAM broker UI without illegally touching . + /// + private readonly IntPtr _ownerHwnd; + + #endregion + } +} diff --git a/doc/apps/AzureSqlConnector/ModeSelectorForm.cs b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs new file mode 100644 index 0000000000..651412de32 --- /dev/null +++ b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs @@ -0,0 +1,116 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Choice exposed by . + /// + internal enum ConnectionMode + { + /// + /// Use , which calls SqlConnection.OpenAsync() on the UI + /// thread. Relies on the WinForms SynchronizationContext to keep the message pump alive. + /// + UiThreadOpenAsync, + + /// + /// Use , which calls SqlConnection.Open() inside + /// Task.Run on a thread-pool worker. The captured form HWND is passed to MSAL. + /// + WorkerThreadOpen, + } + + /// + /// Tiny modal dialog shown at startup that lets the user pick which connector form + /// (UI-thread async or worker-thread sync) to launch. + /// + internal sealed class ModeSelectorForm : Form + { + private readonly RadioButton _rdoUiThread; + private readonly RadioButton _rdoWorker; + + internal ConnectionMode SelectedMode => + _rdoWorker.Checked ? ConnectionMode.WorkerThreadOpen : ConnectionMode.UiThreadOpenAsync; + + internal ModeSelectorForm() + { + Text = "Azure SQL Connector — Choose Mode"; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterScreen; + MaximizeBox = false; + MinimizeBox = false; + ClientSize = new Size(460, 200); + + Label lblHeader = new Label + { + AutoSize = false, + Text = "Select how SqlConnection.Open should be invoked:", + Location = new Point(16, 14), + Size = new Size(420, 20), + Font = new Font(Font, FontStyle.Bold), + }; + + _rdoUiThread = new RadioButton + { + Text = "&UI thread", + Location = new Point(20, 42), + Size = new Size(420, 20), + Checked = true, + }; + + Label lblUiHint = new Label + { + AutoSize = false, + Text = " Async/Sync open on the UI thread; SynchronizationContext keeps the form responsive.", + Location = new Point(20, 62), + Size = new Size(420, 18), + ForeColor = SystemColors.GrayText, + }; + + _rdoWorker = new RadioButton + { + Text = "&Worker thread", + Location = new Point(20, 90), + Size = new Size(420, 20), + }; + + Label lblWorkerHint = new Label + { + AutoSize = false, + Text = " Sync open on a thread-pool worker; HWND is captured up-front for MSAL.", + Location = new Point(20, 110), + Size = new Size(420, 18), + ForeColor = SystemColors.GrayText, + }; + + Button btnOk = new Button + { + Text = "&Launch", + DialogResult = DialogResult.OK, + Location = new Point(268, 152), + Size = new Size(82, 28), + }; + + Button btnCancel = new Button + { + Text = "Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(358, 152), + Size = new Size(82, 28), + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + + Controls.AddRange(new Control[] + { + lblHeader, + _rdoUiThread, lblUiHint, + _rdoWorker, lblWorkerHint, + btnOk, btnCancel, + }); + } + } +} diff --git a/doc/apps/AzureSqlConnector/Program.cs b/doc/apps/AzureSqlConnector/Program.cs new file mode 100644 index 0000000000..c4bfad836c --- /dev/null +++ b/doc/apps/AzureSqlConnector/Program.cs @@ -0,0 +1,39 @@ +using System; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Application entry point for the Azure SQL Connector WinForms test app. + /// + internal static class Program + { + /// + /// The main entry point for the application. Shows a small chooser dialog at startup so + /// the user can pick between the UI-thread and the worker-thread + /// variant of the connector. + /// + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + ConnectionMode mode; + using (ModeSelectorForm selector = new ModeSelectorForm()) + { + if (selector.ShowDialog() != DialogResult.OK) + { + return; + } + mode = selector.SelectedMode; + } + + Form main = mode == ConnectionMode.WorkerThreadOpen + ? (Form)new MainFormWorker() + : new MainForm(); + + Application.Run(main); + } + } +} diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md new file mode 100644 index 0000000000..62f100e966 --- /dev/null +++ b/doc/apps/AzureSqlConnector/README.md @@ -0,0 +1,137 @@ +# Azure SQL Connector (WinForms) + +A small Windows Forms test application that lets a user fill in Azure SQL Database connection +parameters in a UI, builds the corresponding ADO.NET connection string via +`SqlConnectionStringBuilder`, and tests connectivity using `Microsoft.Data.SqlClient`. + +It is intended as a quick, repeatable scratch tool for manually validating connection-string +combinations (server / database / authentication mode / encryption / etc.) against an Azure SQL DB +or SQL Server instance, **and as a manual repro** for the WAM-broker behavior added in this +branch's `ActiveDirectoryAuthenticationProvider`. + +The sample multi-targets: + +| TFM | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------ | +| `net481` | Exercises the legacy `SetIWin32WindowFunc` API used by .NET Framework callers with WinForms. | +| `net10.0-windows` | Exercises the modern `SetParentActivityOrWindowFunc` API used on .NET 8+. | + +`net10.0-windows` restores and builds cleanly on Linux/macOS hosts even though the resulting +binary only runs on Windows, so the project no longer needs a separate no-op cross-platform +fallback. + +> **Note:** `SetParentActivityOrWindowFunc` is also available on `net481` and is the +> recommended API for new code on any framework. The sample wires `net481` up to +> `SetIWin32WindowFunc` only to keep coverage of that legacy code path; replacing the +> `SetIWin32WindowFunc(() => this)` call with `SetParentActivityOrWindowFunc(() => this.Handle)` +> on `net481` works the same way. + +## Mode selector + +When the app launches it shows a small `ModeSelectorForm` that picks between two top-level forms: + +| Mode | Form | What it exercises | +| ---------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **UI thread (`OpenAsync`)** | `MainForm` | Calls `SqlConnection.OpenAsync()` on the UI thread so the Windows Forms message pump stays alive during MSAL sign-in. | +| **Worker thread (`Open`, sync)** | `MainFormWorker` | Calls `SqlConnection.Open()` on a background worker thread; the parent window handle is captured up-front on the UI thread. | + +Both forms demonstrate the supported patterns for parenting the WAM broker (or the legacy +embedded WebView on .NET Framework). + +## Form inputs + +| Field | Maps to connection string keyword | +| -------------------------- | ----------------------------------------------- | +| Server name | `Data Source` | +| Database name | `Initial Catalog` *(only added when non-empty)* | +| Authentication | `Authentication` *(SqlAuthenticationMethod)* | +| User ID | `User ID` | +| Password | `Password` | +| Encrypt | `Encrypt` *(Mandatory / Optional / Strict)* | +| Trust server certificate | `TrustServerCertificate` | +| Connect timeout (s) | `Connect Timeout` | + +The **Authentication** dropdown is populated from every member of +`Microsoft.Data.SqlClient.SqlAuthenticationMethod`. The User ID and Password fields are enabled / +disabled automatically based on the selected method: + +- **SqlPassword** / **ActiveDirectoryPassword** — both User ID and Password are required. +- **ActiveDirectoryServicePrincipal** — User ID = App (Client) ID, Password = client secret. +- **ActiveDirectoryManagedIdentity / MSI / Default / Interactive / DeviceCodeFlow / WorkloadIdentity** + — User ID is optional (e.g. user-assigned MI client id), Password is disabled. +- **ActiveDirectoryIntegrated** — credentials come from the OS, both fields disabled. + +## Buttons + +| Button | Action | +| ----------------------- | ---------------------------------------------------------------------- | +| Build Connection String | Builds the connection string from the form values and displays it. | +| Test Connection | Builds the connection string and opens the connection. | +| Copy to Clipboard | Copies the currently-built connection string to the clipboard. | +| Clear All | Resets every input field to its default state. | +| Who Am I? | Connects and runs an identity query (`SUSER_SNAME()`, `ORIGINAL_LOGIN()`, `USER_NAME()`, `DB_NAME()`, `@@SPID`, etc.) and prints the results. | + +The result pane shows the built connection string with the password masked, the test connection +outcome (including SQL error number when applicable), and the server version on success. + +## Prerequisites + +- Visual Studio 2026 (or any IDE / SDK with .NET Framework **4.8.1** Developer Pack installed) for + the `net481` target. The `net10.0-windows` target only needs the .NET 10 SDK. +- Network connectivity to your Azure SQL Database (server firewall must allow your client IP). +- For Entra ID authentication modes, valid credentials available through Azure CLI / environment + variables / managed identity / the WAM broker, depending on the chosen method. + +## Build & run + +From the project folder: + +```pwsh +dotnet build .\AzureSqlConnector.csproj +dotnet run --project .\AzureSqlConnector.csproj -f net10.0-windows # modern WAM API +dotnet run --project .\AzureSqlConnector.csproj -f net481 # legacy IWin32Window API +``` + +Or load `src\Microsoft.Data.SqlClient.slnx` in Visual Studio, set **AzureSqlConnector** as the +startup project, and press **F5**. + +## Example + +1. **Server name:** `myserver.database.windows.net` +2. **Database name:** `MyDb` +3. **Authentication:** `SqlPassword` +4. **User ID:** `sqladmin` +5. **Password:** *your password* +6. **Encrypt:** `Mandatory` +7. **Trust server certificate:** unchecked +8. Click **Test Connection** — the result pane should display + `Connected successfully! Server version: 12.00.xxxx`. + +## Entra ID parent-window plumbing + +For any `ActiveDirectory*` authentication method (especially **ActiveDirectoryInteractive**) the +app installs an `ActiveDirectoryAuthenticationProvider` and tells it which window should host the +sign-in UI: + +- On **`net481`** the form calls `provider.SetIWin32WindowFunc(() => this)`. This is the legacy + API used by .NET Framework callers with the embedded WebView. +- On **`net10.0-windows`** the form calls + `provider.SetParentActivityOrWindowFunc(() => this.Handle)`. This is the modern API that also + integrates with the WAM broker on Windows. + +The provider is registered for every `SqlAuthenticationMethod.ActiveDirectory*` value at startup. + +### Threading patterns + +| Form | Open mode | Parent window callback | +| ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `MainForm` | `OpenAsync` on UI thread | Callback runs on the UI thread when MSAL invokes it, so `this`/`this.Handle` is naturally safe to access. | +| `MainFormWorker` | `Open` (sync) on worker | The form captures `this.Handle` into a field on the UI thread before kicking off the worker; the callback closes over that captured value so it never needs to marshal back. | + +Without one of these patterns the WAM broker (or the embedded WebView on .NET Framework) can fail +to render or stay unresponsive while it waits for the user. + +## Notes + +- This is a sample / diagnostic tool, **not** a product. It does not persist credentials. +- From the repo root: `dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj` diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 36f10a6aab..4dd9f61d5a 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -1,4 +1,9 @@ - + + @@ -74,7 +79,12 @@ Clears cached user tokens from the token provider. - This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + + The driver's per-pool federated-authentication token cache is also cleared, so subsequent calls will reacquire fed-auth tokens instead of reusing cached entries. + diff --git a/eng/pipelines/common/templates/jobs/ci-build-nugets-job.yml b/eng/pipelines/common/templates/jobs/ci-build-nugets-job.yml index 5e17b506c1..43791bb0d7 100644 --- a/eng/pipelines/common/templates/jobs/ci-build-nugets-job.yml +++ b/eng/pipelines/common/templates/jobs/ci-build-nugets-job.yml @@ -143,16 +143,29 @@ jobs: abstractionsPackageVersion: ${{parameters.abstractionsPackageVersion}} loggingPackageVersion: ${{ parameters.loggingPackageVersion }} - - template: /eng/pipelines/common/templates/steps/generate-nuget-package-step.yml@self - parameters: - buildConfiguration: ${{ parameters.buildConfiguration }} - displayName: 'Create MDS NuGet Package' - generateSymbolsPackage: true - nuspecPath: 'tools/specs/Microsoft.Data.SqlClient.nuspec' - outputDirectory: $(packagePath) - packageVersion: ${{ parameters.mdsPackageVersion }} - properties: 'AbstractionsPackageVersion=${{ parameters.abstractionsPackageVersion }};LoggingPackageVersion=${{ parameters.loggingPackageVersion }}' - referenceType: ${{ parameters.referenceType }} + # Create the MDS NuGet package via build.proj's GenerateMdsPackage target. + # This target has access to the version range properties computed in + # Versions.props, so it passes them through to nuget pack automatically. + - task: NuGetToolInstaller@1 + displayName: 'Install NuGet' + inputs: + checkLatest: true + + - task: DotNetCoreCLI@2 + displayName: 'Create MDS NuGet Package' + inputs: + command: build + projects: build.proj + arguments: >- + -t:GenerateMdsPackage + -p:GenerateNuget=true + -p:Configuration=${{ parameters.buildConfiguration }} + -p:ReferenceType=${{ parameters.referenceType }} + -p:MdsPackageVersion=${{ parameters.mdsPackageVersion }} + -p:AbstractionsPackageVersion=${{ parameters.abstractionsPackageVersion }} + -p:LoggingPackageVersion=${{ parameters.loggingPackageVersion }} + -p:PackagesDir=$(packagePath) + -p:NuGetCmd=nuget.exe # When building in Package mode, the AKV Provider restore needs to find the MDS package # we just built. Copy it to the local NuGet feed so NuGet.config can resolve it. diff --git a/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml b/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml index a989bf19bf..6e1541f939 100644 --- a/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml +++ b/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml @@ -34,8 +34,19 @@ steps: brew install docker brew tap microsoft/mssql-release https://github.com/Microsoft/homebrew-mssql-release brew update + # Homebrew 5.2+ requires explicit trust for third-party taps. Run this + # after 'brew update' so the trust command is available even if the runner + # image shipped an older Homebrew version. + brew trust microsoft/mssql-release HOMEBREW_ACCEPT_EULA=Y brew install mssql-tools18 + # Fail fast if sqlcmd was not installed (e.g. tap-trust or formula error). + # Without this check the script would loop for ~6 minutes trying to connect. + if ! command -v sqlcmd &>/dev/null; then + echo "ERROR: sqlcmd is not on PATH after brew install. Check the mssql-tools18 installation above." + exit 1 + fi + # Start Colima with Virtualization.framework for x86_64 binary translation # on Apple Silicon. Rosetta/binfmt emulation is enabled by default in # Colima >= 0.8 when using --vm-type vz, which is dramatically faster than diff --git a/eng/pipelines/common/templates/steps/generate-nuget-package-step.yml b/eng/pipelines/common/templates/steps/generate-nuget-package-step.yml deleted file mode 100644 index 46c5c61492..0000000000 --- a/eng/pipelines/common/templates/steps/generate-nuget-package-step.yml +++ /dev/null @@ -1,80 +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. # -################################################################################# -parameters: - - name: nuspecPath - type: string - - - name: packageVersion - type: string - - - name: outputDirectory - type: string - default: '$(Build.SourcesDirectory)/output' - - # The C# build configuration (e.g. Debug or Release) to use when building the NuGet package. - - name: buildConfiguration - type: string - default: Debug - values: - - Debug - - Release - - - name: generateSymbolsPackage - type: boolean - - - name: displayName - type: string - - - name: installNuget - type: boolean - default: true - - # The C# project reference type to use when building and packing the packages. - - name: referenceType - type: string - values: - # Reference sibling packages as NuGet packages. - - Package - # Reference sibling packages as C# projects. - - Project - - # Semi-colon separated properties to pass to nuget pack via the -properties argument. - - name: properties - type: string - default: '' - -steps: -- ${{ if parameters.installNuget }}: - - task: NuGetToolInstaller@1 - displayName: 'Install Latest Nuget' - inputs: - checkLatest: true - -- powershell: | - $Commit=git rev-parse HEAD - Write-Host "##vso[task.setvariable variable=CommitHead;]$Commit" - displayName: CommitHead - -- task: NuGetCommand@2 - displayName: ${{parameters.displayName }} - inputs: - command: custom - ${{ if parameters.generateSymbolsPackage }}: - arguments: >- - pack - -Symbols - -SymbolPackageFormat snupkg - ${{parameters.nuspecPath}} - -Version ${{parameters.packageVersion}} - -OutputDirectory ${{parameters.outputDirectory}} - -properties "COMMITID=$(CommitHead);Configuration=${{parameters.buildConfiguration}};ReferenceType=${{parameters.referenceType}};${{parameters.properties}}" - ${{else }}: - arguments: >- - pack - ${{parameters.nuspecPath}} - -Version ${{parameters.packageVersion}} - -OutputDirectory ${{parameters.outputDirectory}} - -properties "COMMITID=$(CommitHead);Configuration=${{parameters.buildConfiguration}};ReferenceType=${{parameters.referenceType}};${{parameters.properties}}" diff --git a/eng/pipelines/dotnet-sqlclient-ci-core.yml b/eng/pipelines/dotnet-sqlclient-ci-core.yml index f9ead686ff..7cedad97f6 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-core.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-core.yml @@ -149,6 +149,9 @@ stages: buildConfiguration: ${{ parameters.buildConfiguration }} debug: ${{ parameters.debug }} dotnetVerbosity: ${{ parameters.dotnetVerbosity }} + loggingArtifactsName: $(loggingArtifactsName) + loggingPackageVersion: $(loggingPackageVersion) + referenceType: ${{ parameters.referenceType }} # When building Abstractions via packages, we must depend on the Logging # package. ${{ if eq(parameters.referenceType, 'Package') }}: @@ -198,6 +201,8 @@ stages: - build_logging_package_stage - build_sqlclient_package_stage dotnetVerbosity: ${{ parameters.dotnetVerbosity }} + loggingArtifactsName: $(loggingArtifactsName) + loggingPackageVersion: $(loggingPackageVersion) mdsArtifactsName: $(mdsArtifactsName) mdsPackageVersion: $(mdsPackageVersion) referenceType: ${{ parameters.referenceType }} diff --git a/eng/pipelines/jobs/pack-abstractions-package-ci-job.yml b/eng/pipelines/jobs/pack-abstractions-package-ci-job.yml index 9db1c05696..adfe91dd7b 100644 --- a/eng/pipelines/jobs/pack-abstractions-package-ci-job.yml +++ b/eng/pipelines/jobs/pack-abstractions-package-ci-job.yml @@ -53,6 +53,30 @@ parameters: - detailed - diagnostic + # The name of the Logging pipeline artifacts to download. + # + # This is used when the referenceType is 'Package'. + - name: loggingArtifactsName + type: string + default: Logging.Artifacts + + # The Logging package version to depend on. + # + # This is used when the referenceType is 'Package'. + - name: loggingPackageVersion + type: string + default: '' + + # The C# project reference type to use when building and packing the packages. + - name: referenceType + type: string + default: Project + values: + # Reference sibling packages as NuGet packages. + - Package + # Reference sibling packages as C# projects. + - Project + jobs: - job: pack_abstractions_package_job @@ -104,21 +128,46 @@ jobs: - pwsh: 'Get-ChildItem Env: | Sort-Object Name' displayName: '[Debug] Print Environment Variables' + # For Package reference builds, we must first download the dependency + # package artifacts. + - ${{ if eq(parameters.referenceType, 'Package') }}: + - task: DownloadPipelineArtifact@2 + displayName: Download Logging Package Artifacts + inputs: + artifactName: ${{ parameters.loggingArtifactsName }} + targetPath: $(Build.SourcesDirectory)/packages + # Install the .NET SDK. - template: /eng/pipelines/steps/install-dotnet.yml@self parameters: debug: ${{ parameters.debug }} # Create the NuGet packages. - - task: DotNetCoreCLI@2 - displayName: Create NuGet Package - inputs: - command: pack - packagesToPack: $(project) - configurationToPack: ${{ parameters.buildConfiguration }} - packDirectory: $(dotnetPackagesDir) - verbosityToPack: ${{ parameters.dotnetVerbosity }} - buildProperties: AbstractionsPackageVersion=${{ parameters.abstractionsPackageVersion }};AbstractionsAssemblyFileVersion=${{ parameters.abstractionsAssemblyFileVersion }} + # + # When referenceType is Package, we must pass ReferenceType and the + # dependency version so that Directory.Packages.props applies version + # ranges to sibling package dependencies. + - ${{ if eq(parameters.referenceType, 'Package') }}: + - task: DotNetCoreCLI@2 + displayName: Create NuGet Package + inputs: + command: pack + packagesToPack: $(project) + configurationToPack: ${{ parameters.buildConfiguration }} + packDirectory: $(dotnetPackagesDir) + verbosityToPack: ${{ parameters.dotnetVerbosity }} + buildProperties: AbstractionsPackageVersion=${{ parameters.abstractionsPackageVersion }};AbstractionsAssemblyFileVersion=${{ parameters.abstractionsAssemblyFileVersion }};ReferenceType=Package;LoggingPackageVersion=${{ parameters.loggingPackageVersion }} + + - ${{ else }}: + - task: DotNetCoreCLI@2 + displayName: Create NuGet Package + inputs: + command: pack + packagesToPack: $(project) + configurationToPack: ${{ parameters.buildConfiguration }} + packDirectory: $(dotnetPackagesDir) + verbosityToPack: ${{ parameters.dotnetVerbosity }} + buildProperties: AbstractionsPackageVersion=${{ parameters.abstractionsPackageVersion }};AbstractionsAssemblyFileVersion=${{ parameters.abstractionsAssemblyFileVersion }} # Publish the NuGet packages as a named pipeline artifact. - task: PublishPipelineArtifact@1 diff --git a/eng/pipelines/jobs/pack-azure-package-ci-job.yml b/eng/pipelines/jobs/pack-azure-package-ci-job.yml index 95853d43e0..4afa8ceaae 100644 --- a/eng/pipelines/jobs/pack-azure-package-ci-job.yml +++ b/eng/pipelines/jobs/pack-azure-package-ci-job.yml @@ -26,12 +26,19 @@ parameters: type: string default: Logging.Artifacts - # The Abstractions package verion to depend on. + # The Abstractions package version to depend on. # # This is used when the referenceType is 'Package'. - name: abstractionsPackageVersion type: string + # The Logging package version to depend on. + # + # This is used when the referenceType is 'Package'. + - name: loggingPackageVersion + type: string + default: '' + # The name of the pipeline artifacts to publish. - name: azureArtifactsName type: string @@ -153,15 +160,31 @@ jobs: debug: ${{ parameters.debug }} # Create the NuGet packages. - - task: DotNetCoreCLI@2 - displayName: Create NuGet Package - inputs: - command: pack - packagesToPack: $(project) - configurationToPack: ${{ parameters.buildConfiguration }} - packDirectory: $(dotnetPackagesDir) - verbosityToPack: ${{ parameters.dotnetVerbosity }} - buildProperties: AzurePackageVersion=${{ parameters.azurePackageVersion }};AzureAssemblyFileVersion=${{ parameters.azureAssemblyFileVersion }} + # + # When referenceType is Package, we must pass ReferenceType and the + # dependency versions so that Directory.Packages.props applies version + # ranges to sibling package dependencies. + - ${{ if eq(parameters.referenceType, 'Package') }}: + - task: DotNetCoreCLI@2 + displayName: Create NuGet Package + inputs: + command: pack + packagesToPack: $(project) + configurationToPack: ${{ parameters.buildConfiguration }} + packDirectory: $(dotnetPackagesDir) + verbosityToPack: ${{ parameters.dotnetVerbosity }} + buildProperties: AzurePackageVersion=${{ parameters.azurePackageVersion }};AzureAssemblyFileVersion=${{ parameters.azureAssemblyFileVersion }};ReferenceType=Package;LoggingPackageVersion=${{ parameters.loggingPackageVersion }};AbstractionsPackageVersion=${{ parameters.abstractionsPackageVersion }} + + - ${{ else }}: + - task: DotNetCoreCLI@2 + displayName: Create NuGet Package + inputs: + command: pack + packagesToPack: $(project) + configurationToPack: ${{ parameters.buildConfiguration }} + packDirectory: $(dotnetPackagesDir) + verbosityToPack: ${{ parameters.dotnetVerbosity }} + buildProperties: AzurePackageVersion=${{ parameters.azurePackageVersion }};AzureAssemblyFileVersion=${{ parameters.azureAssemblyFileVersion }} # Publish the NuGet packages as a named pipeline artifact. - task: PublishPipelineArtifact@1 diff --git a/eng/pipelines/kerberos/build-and-test-steps.yml b/eng/pipelines/kerberos/build-and-test-steps.yml new file mode 100644 index 0000000000..8ba9ccdee8 --- /dev/null +++ b/eng/pipelines/kerberos/build-and-test-steps.yml @@ -0,0 +1,144 @@ +################################################################################# +# 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. # +################################################################################# + +# Shared build-and-test steps used by both the Windows and Linux Kerberos jobs. +# +# Parameters: +# buildTarget — The build.proj target that builds SqlClient for the current +# OS (e.g. BuildSqlClientWindows or BuildSqlClientUnix). +# testFramework — The TFM to test against (e.g. net9.0, net462). +# testRunTitle — Title for the published test results (displayed in the ADO +# Tests tab). +# artifactName — Name of the published pipeline artifact that carries the +# test results and coverage files. + +parameters: + + # build.proj target to build SqlClient for the current OS. + - name: buildTarget + type: string + + # TFM to pass to the test targets (-p:TestFramework). + - name: testFramework + type: string + + # Title shown in the ADO Tests tab for this run. + - name: testRunTitle + type: string + + # Pipeline artifact name for test results and coverage. + - name: artifactName + type: string + +steps: + + # --------------------------------------------------------------------------- + # Build + # --------------------------------------------------------------------------- + + # Build the given target. + # + # The test stages build as part of their targets, but this separate step isolates build failures + # so we can fail fast before running tests. Retries are enabled intentionally (1 attempt for + # build, 2 attempts for test steps) to reduce transient infrastructure-related failures. + # + - task: DotNetCoreCLI@2 + displayName: Build SqlClient + retryCountOnTaskFailure: 1 + inputs: + command: build + projects: build2.proj + arguments: >- + -t:${{ parameters.buildTarget }} + -p:Configuration=Release + + # --------------------------------------------------------------------------- + # Run tests in separate steps to permit focused retries. + # --------------------------------------------------------------------------- + + # Run the Unit Test suite. + - task: DotNetCoreCLI@2 + displayName: Run Unit Tests (${{ parameters.testFramework }}) + retryCountOnTaskFailure: 2 + inputs: + command: build + projects: build2.proj + arguments: > + -t:TestMdsUnit + -p:TestFramework=${{ parameters.testFramework }} + -p:Configuration=Release + + # Run the Functional Test suite. + - task: DotNetCoreCLI@2 + displayName: Run Functional Tests (${{ parameters.testFramework }}) + retryCountOnTaskFailure: 2 + inputs: + command: build + projects: build2.proj + arguments: > + -t:TestMdsFunctional + -p:TestFramework=${{ parameters.testFramework }} + -p:Configuration=Release + + # Run the Manual Test suite. + - task: DotNetCoreCLI@2 + displayName: Run Manual Tests (${{ parameters.testFramework }}) + retryCountOnTaskFailure: 2 + inputs: + command: build + projects: build2.proj + arguments: > + -t:TestMdsManual + -p:TestFramework=${{ parameters.testFramework }} + -p:Configuration=Release + + # --------------------------------------------------------------------------- + # Publish results & coverage + # --------------------------------------------------------------------------- + + # Publish the TRX test results to the pipeline run. + - task: PublishTestResults@2 + displayName: Publish Test Results + condition: succeededOrFailed() + inputs: + testResultsFormat: VSTest + # build.proj defines TestResultsFolderPath which defaults to + # $(Build.SourcesDirectory)/test_results, so we look there for the results and coverage files. + testResultsFiles: $(Build.SourcesDirectory)/test_results/**/*.trx + mergeTestResults: true + testRunTitle: ${{ parameters.testRunTitle }} + buildConfiguration: Release + + # Azure Pipelines task conditions do not support path existence checks directly, + # so compute this once and gate later steps on the variable. + - pwsh: | + $resultsDir = "$(Build.SourcesDirectory)/test_results" + if (Test-Path -LiteralPath $resultsDir) { + Write-Host "##vso[task.setvariable variable=HasTestResultsDir]true" + } + else { + Write-Host "##vso[task.setvariable variable=HasTestResultsDir]false" + } + displayName: Detect test_results directory + condition: succeededOrFailed() + + # Give our coverage files a unique name to make it clear where they originated when we download + # the artifacts from all jobs in the merge stage. + - pwsh: | + cd $(Build.SourcesDirectory)/test_results + Get-ChildItem -Filter "*.coverage" -Recurse | + Rename-Item -NewName { "${{ parameters.testFramework }}" + $_.Name } + displayName: Rename coverage files + condition: and(succeededOrFailed(), eq(variables['HasTestResultsDir'], 'true')) + + # Publish TRX test results and coverage files as pipeline artifacts. The merge stage needs the + # coverage files from all of the jobs. + - task: PublishPipelineArtifact@1 + displayName: Publish Test Artifacts + condition: and(succeededOrFailed(), eq(variables['HasTestResultsDir'], 'true')) + inputs: + targetPath: $(Build.SourcesDirectory)/test_results + artifact: ${{ parameters.artifactName }} diff --git a/eng/pipelines/kerberos/linux-cleanup-step.yml b/eng/pipelines/kerberos/linux-cleanup-step.yml new file mode 100644 index 0000000000..d3491bb337 --- /dev/null +++ b/eng/pipelines/kerberos/linux-cleanup-step.yml @@ -0,0 +1,45 @@ +################################################################################# +# 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. # +################################################################################# + +# This template leaves the Active Directory domain and destroys Kerberos +# credentials. It should be referenced at the end of any job that called +# linux-init-step.yml. +# +# All steps use condition: always() so that cleanup runs even when previous +# steps fail. + +parameters: + + # The Active Directory domain to leave (e.g. mydomain.contoso.com). + - name: kerberosDomain + type: string + + # The domain user account used during the join. + - name: kerberosDomainUser + type: string + + # The password for the domain user account. + - name: kerberosDomainPassword + type: string + +steps: + + - bash: | + set -uo pipefail + + DOMAIN="${{ parameters.kerberosDomain }}" + DOMAIN_USER="${{ parameters.kerberosDomainUser }}" + DOMAIN_PASSWORD="${{ parameters.kerberosDomainPassword }}" + DOMAIN_UPPER=$(echo "$DOMAIN" | tr '[:lower:]' '[:upper:]') + + # Leave the domain + echo "$DOMAIN_PASSWORD" | sudo realm leave "$DOMAIN_UPPER" --verbose \ + -U "$DOMAIN_USER@$DOMAIN_UPPER" || true + + # Destroy the TGT and credential cache + kdestroy || true + displayName: Clean up Kerberos (domain leave + kdestroy) + condition: always() diff --git a/eng/pipelines/kerberos/linux-init-step.yml b/eng/pipelines/kerberos/linux-init-step.yml new file mode 100644 index 0000000000..c0c4603232 --- /dev/null +++ b/eng/pipelines/kerberos/linux-init-step.yml @@ -0,0 +1,117 @@ +################################################################################# +# 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. # +################################################################################# + +# This template joins a Linux agent to an Active Directory domain using Kerberos +# and acquires a TGT (Ticket-Granting Ticket) for the specified domain user. +# +# Prerequisites: +# - The agent must be running on Ubuntu/Debian (uses apt-get). +# - The domain controller must be reachable from the agent network. +# +# After this step completes successfully, the agent will have: +# - Kerberos packages installed (krb5-user, realmd, sssd, adcli, etc.) +# - Hostname set to FQDN within the domain +# - NTP synchronized with the domain controller +# - Machine joined to the AD domain +# - A valid Kerberos TGT for the specified user + +parameters: + + # The Active Directory domain to join (e.g. mydomain.contoso.com). + - name: kerberosDomain + type: string + + # The Organizational Unit in which to place the computer account. + - name: kerberosDomainOU + type: string + + # The domain user account to authenticate with (sAMAccountName, without @realm). + - name: kerberosDomainUser + type: string + + # The password for the domain user account. + - name: kerberosDomainPassword + type: string + +steps: + + - bash: | + set -euo pipefail + + DOMAIN="${{ parameters.kerberosDomain }}" + DOMAIN_OU="${{ parameters.kerberosDomainOU }}" + DOMAIN_USER="${{ parameters.kerberosDomainUser }}" + DOMAIN_PASSWORD="${{ parameters.kerberosDomainPassword }}" + DOMAIN_UPPER=$(echo "$DOMAIN" | tr '[:lower:]' '[:upper:]') + + echo "Domain: $DOMAIN" + echo "Realm: $DOMAIN_UPPER" + echo "User: $DOMAIN_USER" + echo "OU: $DOMAIN_OU" + + if [ -z "$DOMAIN_PASSWORD" ]; then + echo "##vso[task.logissue type=error]KerberosDomainPassword is empty" + exit 1 + fi + + # ----------------------------------------------------------------------- + # Install Kerberos and AD integration packages + # ----------------------------------------------------------------------- + echo 'debconf debconf/frontend select Noninteractive' | sudo debconf-set-selections + + sudo apt-get -y update + sudo apt-get install -y dialog apt-utils + sudo apt-get install -y \ + krb5-user samba sssd sssd-tools libnss-sss libpam-sss \ + ntp ntpdate realmd adcli + + # ----------------------------------------------------------------------- + # Set the hostname to FQDN within the domain + # ----------------------------------------------------------------------- + CURRENT_HOSTNAME="$(hostname)" + if [ "$CURRENT_HOSTNAME" = "$DOMAIN" ] || [[ "$CURRENT_HOSTNAME" == *".$DOMAIN" ]]; then + echo "Hostname already uses domain suffix '.$DOMAIN': $CURRENT_HOSTNAME" + else + sudo hostnamectl set-hostname "$CURRENT_HOSTNAME.$DOMAIN" + fi + + # ----------------------------------------------------------------------- + # Synchronize time with the domain controller (required for Kerberos) + # ----------------------------------------------------------------------- + if ! sudo grep -Fqx "server $DOMAIN" /etc/ntp.conf; then + echo "server $DOMAIN" | sudo tee -a /etc/ntp.conf + fi + sudo systemctl stop ntp + sudo ntpdate "$DOMAIN" + sudo systemctl start ntp + + # ----------------------------------------------------------------------- + # Configure Kerberos realm + # ----------------------------------------------------------------------- + echo "[libdefaults] + default_realm = $DOMAIN_UPPER + rdns = false" | sudo tee /etc/krb5.conf + + # ----------------------------------------------------------------------- + # Discover and join the domain + # ----------------------------------------------------------------------- + sudo realm discover "$DOMAIN_UPPER" + + echo "$DOMAIN_PASSWORD" | sudo realm join --verbose "$DOMAIN_UPPER" \ + -U "$DOMAIN_USER@$DOMAIN_UPPER" \ + --computer-ou "OU=$DOMAIN_OU" + + realm list + + # ----------------------------------------------------------------------- + # Acquire a Kerberos TGT + # ----------------------------------------------------------------------- + echo "$DOMAIN_PASSWORD" | kinit "$DOMAIN_USER@$DOMAIN_UPPER" + + klist + sudo ip addr + sudo ip route + displayName: Initialize Kerberos (domain join + kinit) diff --git a/eng/pipelines/kerberos/sqlclient-kerberos.yml b/eng/pipelines/kerberos/sqlclient-kerberos.yml new file mode 100644 index 0000000000..8770326493 --- /dev/null +++ b/eng/pipelines/kerberos/sqlclient-kerberos.yml @@ -0,0 +1,290 @@ +################################################################################# +# 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. # +################################################################################# + +# ============================================================================= +# sqlclient-kerberos +# ============================================================================= +# Daily Kerberos authentication test pipeline for Microsoft.Data.SqlClient. +# Replaces the Classic "Test-SqlClient-Kerberos-Azure" pipeline. +# +# Schedule: daily at 07:30 UTC on internal/release/7.0. +# +# Job breakdown (10 total): +# Stage: windows → 7 jobs (net462 + net8/9/10 × NativeSNI/ManagedSNI) +# Stage: linux → 3 jobs (net8, net9, net10 — ManagedSNI only) +# Stage: Merge-Code-Coverage → 1 job +# +# Shared steps: +# Build and test steps are defined in build-and-test-steps.yml and reused +# by both stages. Each stage passes its OS-specific build target and the +# per-job testFramework matrix variable. +# +# Required ADO variable groups (link in pipeline UI): +# - kv-sqldrivers-shared (provides the agent-at-sqldrv-ad secret) +# +# Variables defined in this file (no pipeline UI configuration needed): +# - KerberosDomain sqldrv.ad +# - KerberosDomainOU agents +# - KerberosDomainUser agent +# - KerberosDomainPassword $(agent-at-sqldrv-ad) from kv-sqldrivers-shared +# - REMOTE_TCP_CONN_STRING TCP connection string to sqldrv-sql22 +# - REMOTE_NP_CONN_STRING Named Pipe connection string to sqldrv-sql22 +# ============================================================================= + +trigger: none # Scheduled runs only — no CI trigger +pr: none # Not triggered by PRs + +schedules: + - cron: '30 7 * * *' + displayName: Daily run (07:30 UTC) + branches: + include: + - internal/release/7.0 + always: true + +name: $(date:yyyyMMdd)$(rev:.r) + +variables: + # Our Kerberos environment doesn't change often, so we can define these variables here rather than + # in the Pipeline UI or in Libraries. + - name: KerberosDomain + value: sqldrv.ad + + - name: KerberosDomainOU + value: agents + + - name: KerberosDomainUser + value: agent + + - name: KerberosDomainPassword + value: $(agent-at-sqldrv-ad) # Secret from kv-sqldrivers-shared variable group + + - name: REMOTE_TCP_CONN_STRING + value: Data Source=tcp:sqldrv-sql22.sqldrv.ad\sql2022;Initial Catalog=Northwind;Integrated Security=true;Encrypt=false;TrustServerCertificate=true + + - name: REMOTE_NP_CONN_STRING + value: Data Source=np:sqldrv-sql22.sqldrv.ad\sql2022;Initial Catalog=Northwind;Integrated Security=true;Encrypt=false;TrustServerCertificate=true + +# ============================================================================= +# STAGES +# ============================================================================= +stages: + + # =========================================================================== + # Stage 1 - Windows (7 jobs = net462 + 3 TFs × 2 ManagedSNI) + # =========================================================================== + - stage: windows + displayName: Windows + dependsOn: [] + variables: + # KerberosDomainPassword expands at runtime in this stage from the agent-at-sqldrv-ad secret + # exposed by this variable group. + - group: kv-sqldrivers-shared + jobs: + - job: windows + displayName: Windows + timeoutInMinutes: 90 + workspace: + clean: all # Purge obj/artifacts from prior runs on self-hosted agents + strategy: + matrix: + # Azure Pipelines exposes matrix variables as environment variables for each step. + # Do not use the name targetFramework here: MSBuild imports environment variables as + # properties, and TargetFramework would leak into dotnet build build.proj, forcing + # transitive project references onto an invalid TFM (for example SqlServer.Server -> net9.0). + net462_NativeSNI: + testFramework: net462 + managedSNI: 'false' + net8_NativeSNI: + testFramework: net8.0 + managedSNI: 'false' + net8_ManagedSNI: + testFramework: net8.0 + managedSNI: 'true' + net9_NativeSNI: + testFramework: net9.0 + managedSNI: 'false' + net9_ManagedSNI: + testFramework: net9.0 + managedSNI: 'true' + net10_NativeSNI: + testFramework: net10.0 + managedSNI: 'false' + net10_ManagedSNI: + testFramework: net10.0 + managedSNI: 'true' + pool: + name: ADO-Trusted-Domain-Win-WestUS2 + demands: + - ImageOverride -equals ADO-MMS22-SQL19 + steps: + + - checkout: self + clean: true + fetchDepth: 1 + fetchTags: false + + - template: /eng/pipelines/steps/install-dotnet.yml@self + parameters: + runtimes: [8.x, 9.x] + + # --- Update test configuration --- + # Uses runtime variables from the matrix ($(managedSNI)) so we use + # inline PowerShell instead of the shared config template which + # requires compile-time parameters. + - pwsh: | + $managedSni = [System.Convert]::ToBoolean($env:MANAGED_SNI) + $jdata = Get-Content -Raw "config.default.json" | ConvertFrom-Json + foreach ($p in $jdata) { + $p.TCPConnectionString = $env:REMOTE_TCP_CONN_STRING + $p.NPConnectionString = $env:REMOTE_NP_CONN_STRING + $p.SupportsIntegratedSecurity = $true + $p.UseManagedSNIOnWindows = $managedSni + } + $jdata | ConvertTo-Json | Set-Content "config.json" + workingDirectory: src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities + displayName: Update test config.json + env: + REMOTE_TCP_CONN_STRING: $(REMOTE_TCP_CONN_STRING) + REMOTE_NP_CONN_STRING: $(REMOTE_NP_CONN_STRING) + MANAGED_SNI: $(managedSNI) + + # --- Prepare Windows services --- + - powershell: | + $svc = Get-Service -Name SQLBrowser -ErrorAction SilentlyContinue + if ($null -ne $svc) { + Set-Service -StartupType Automatic SQLBrowser + if ($svc.Status -ne 'Running') { Start-Service SQLBrowser } + Get-Service SQLBrowser | Select-Object Name, StartType, Status + } + displayName: Start SQL Server Browser + + - powershell: | + Set-DtcNetworkSetting -DtcName "Local" ` + -InboundTransactionsEnabled $true ` + -OutboundTransactionsEnabled $true ` + -RemoteClientAccessEnabled $true ` + -Confirm:$false + + Get-NetFirewallRule -DisplayName "Distributed Transaction Coordinator (RPC)" | Set-NetFirewallRule -Profile Domain -Action Allow -Enabled True + Get-NetFirewallRule -DisplayName "Distributed Transaction Coordinator (RPC-EPMAP)" | Set-NetFirewallRule -Profile Domain -Action Allow -Enabled True + Get-NetFirewallRule -DisplayName "Distributed Transaction Coordinator (TCP-Out)" | Set-NetFirewallRule -Profile Domain -Action Allow -Enabled True + Get-NetFirewallRule -DisplayName "Distributed Transaction Coordinator (TCP-In)" | Set-NetFirewallRule -Profile Domain -Action Allow -Enabled True + displayName: Enable Network DTC Access + + # --- Build and test --- + - template: /eng/pipelines/kerberos/build-and-test-steps.yml@self + parameters: + buildTarget: BuildMdsWindows + testFramework: $(testFramework) + testRunTitle: Windows-$(testFramework)-ManagedSNI_$(managedSNI) + artifactName: $(testFramework)-ManagedSNI_$(managedSNI)-$(System.JobId) + + # =========================================================================== + # Stage 2 - Linux - .NET Core + Kerberos (3 jobs = 3 TFs) + # =========================================================================== + - stage: linux + displayName: Linux + dependsOn: [] + variables: + # KerberosDomainPassword expands at runtime in this stage from the agent-at-sqldrv-ad secret + # exposed by this variable group. + - group: kv-sqldrivers-shared + jobs: + - job: linux + displayName: Linux + timeoutInMinutes: 90 + workspace: + clean: all # Purge leftovers on self-hosted agents to reduce cross-run flakiness + strategy: + matrix: + net8: + testFramework: net8.0 + net9: + testFramework: net9.0 + net10: + testFramework: net10.0 + pool: + name: ADO-Trusted-Linux-WestUS2 + demands: + - ImageOverride -equals ADO-UB20-SQL22 + + steps: + + - checkout: self + clean: true + fetchDepth: 1 + fetchTags: false + + # --- Install .NET SDK and runtimes --- + - template: /eng/pipelines/steps/install-dotnet.yml@self + parameters: + runtimes: [8.x, 9.x] + + # --- Update test configuration (with Kerberos credentials) --- + - pwsh: | + $jdata = Get-Content -Raw "config.default.json" | ConvertFrom-Json + foreach ($p in $jdata) { + $p.TCPConnectionString = $env:REMOTE_TCP_CONN_STRING + $p.NPConnectionString = $env:REMOTE_NP_CONN_STRING + $p.SupportsIntegratedSecurity = $true + } + $jdata | Add-Member -NotePropertyName "KerberosDomainUser" -NotePropertyValue $env:KERBEROS_DOMAIN_USER -Force + $jdata | Add-Member -NotePropertyName "KerberosDomainPassword" -NotePropertyValue $env:KERBEROS_DOMAIN_PASSWORD -Force + $jdata | ConvertTo-Json | Set-Content "config.json" + workingDirectory: src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities + displayName: Update test config.json (Kerberos) + env: + REMOTE_TCP_CONN_STRING: $(REMOTE_TCP_CONN_STRING) + REMOTE_NP_CONN_STRING: $(REMOTE_NP_CONN_STRING) + KERBEROS_DOMAIN_USER: $(KerberosDomainUser) + KERBEROS_DOMAIN_PASSWORD: $(KerberosDomainPassword) + + # --- Kerberos domain join --- + - template: /eng/pipelines/kerberos/linux-init-step.yml@self + parameters: + kerberosDomain: $(KerberosDomain) + kerberosDomainOU: $(KerberosDomainOU) + kerberosDomainUser: $(KerberosDomainUser) + kerberosDomainPassword: $(KerberosDomainPassword) + + # --- Verify SQL connectivity --- + - pwsh: | + Install-Module -Name SqlServer -Force -Confirm:$false + Import-Module SqlServer + Invoke-Sqlcmd -Query "SELECT @@VERSION, @@SERVERNAME" -ConnectionString $env:REMOTE_TCP_CONN_STRING + displayName: Verify SQL connectivity + env: + REMOTE_TCP_CONN_STRING: $(REMOTE_TCP_CONN_STRING) + + # --- Build and test --- + - template: /eng/pipelines/kerberos/build-and-test-steps.yml@self + parameters: + buildTarget: BuildMdsUnix + testFramework: $(testFramework) + testRunTitle: Linux-$(testFramework) + artifactName: $(testFramework)-linux-$(System.JobId) + + # --- Kerberos cleanup (always runs) --- + - template: /eng/pipelines/kerberos/linux-cleanup-step.yml@self + parameters: + kerberosDomain: $(KerberosDomain) + kerberosDomainUser: $(KerberosDomainUser) + kerberosDomainPassword: $(KerberosDomainPassword) + + # =========================================================================== + # Stage 3 — Merge Code Coverage (1 job) + # =========================================================================== + - stage: merge + displayName: Merge Code Coverage + dependsOn: + - windows + - linux + condition: succeededOrFailed() + jobs: + - template: /eng/pipelines/common/templates/jobs/ci-code-coverage-job.yml@self + parameters: + upload: false diff --git a/eng/pipelines/libraries/ci-build-variables.yml b/eng/pipelines/libraries/ci-build-variables.yml index 2779277678..9d058ce078 100644 --- a/eng/pipelines/libraries/ci-build-variables.yml +++ b/eng/pipelines/libraries/ci-build-variables.yml @@ -24,43 +24,43 @@ variables: # Abstractions library assembly file version - name: abstractionsAssemblyFileVersion - value: 1.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # Abstractions library NuGet package version - name: abstractionsPackageVersion - value: 1.0.0.$(Build.BuildNumber)-ci + value: 7.0.2.$(Build.BuildNumber)-ci # AKV provider assembly file version - name: akvAssemblyFileVersion - value: 7.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # AKV provider NuGet package version - name: akvPackageVersion - value: 7.0.0.$(Build.BuildNumber)-ci + value: 7.0.2.$(Build.BuildNumber)-ci # Azure library assembly file version - name: azureAssemblyFileVersion - value: 1.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # Azure library NuGet package version - name: azurePackageVersion - value: 1.0.0.$(Build.BuildNumber)-ci + value: 7.0.2.$(Build.BuildNumber)-ci # Logging library assembly file version - name: loggingAssemblyFileVersion - value: 1.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # Logging library NuGet package version - name: loggingPackageVersion - value: 1.0.0.$(Build.BuildNumber)-ci + value: 7.0.2.$(Build.BuildNumber)-ci # MDS library assembly file version - name: mdsAssemblyFileVersion - value: 7.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # MDS library NuGet package version - name: mdsPackageVersion - value: 7.0.0.$(Build.BuildNumber)-ci + value: 7.0.2.$(Build.BuildNumber)-ci # Local NuGet feed directory where downloaded pipeline artifacts are placed. # NuGet.config references this as a local package source for restore. diff --git a/eng/pipelines/onebranch/jobs/build-signed-sqlclient-package-job.yml b/eng/pipelines/onebranch/jobs/build-signed-sqlclient-package-job.yml index 98f5c811aa..d3a6245782 100644 --- a/eng/pipelines/onebranch/jobs/build-signed-sqlclient-package-job.yml +++ b/eng/pipelines/onebranch/jobs/build-signed-sqlclient-package-job.yml @@ -93,17 +93,29 @@ jobs: sourceRoot: $(BUILD_OUTPUT) dllPattern: 'Microsoft.Data.SqlClient.resources.dll' - - template: /eng/pipelines/common/templates/steps/generate-nuget-package-step.yml@self - parameters: - buildConfiguration: Release - displayName: 'Create MDS NuGet Package' - generateSymbolsPackage: true - installNuget: false - nuspecPath: $(nuspecPath) - outputDirectory: $(PACK_OUTPUT) - packageVersion: $(mdsPackageVersion) - properties: 'AbstractionsPackageVersion=$(abstractionsPackageVersion);LoggingPackageVersion=$(loggingPackageVersion)' - referenceType: Package + # Create the MDS NuGet package via build.proj's GenerateMdsPackage target. + # This target has access to the version range properties computed in + # Versions.props, so it passes them through to nuget pack automatically. + - task: NuGetToolInstaller@1 + displayName: 'Install NuGet' + inputs: + checkLatest: true + + - task: DotNetCoreCLI@2 + displayName: 'Create MDS NuGet Package' + inputs: + command: build + projects: $(REPO_ROOT)/build.proj + arguments: >- + -t:GenerateMdsPackage + -p:GenerateNuget=true + -p:Configuration=Release + -p:ReferenceType=Package + -p:MdsPackageVersion=$(mdsPackageVersion) + -p:AbstractionsPackageVersion=$(abstractionsPackageVersion) + -p:LoggingPackageVersion=$(loggingPackageVersion) + -p:PackagesDir=$(PACK_OUTPUT) + -p:NuGetCmd=nuget.exe - template: /eng/pipelines/onebranch/steps/esrp-code-signing-step.yml@self parameters: diff --git a/eng/pipelines/onebranch/sqlclient-official.yml b/eng/pipelines/onebranch/sqlclient-official.yml index c9548b25d6..4bc17988eb 100644 --- a/eng/pipelines/onebranch/sqlclient-official.yml +++ b/eng/pipelines/onebranch/sqlclient-official.yml @@ -9,20 +9,20 @@ name: $(Year:YY)$(DayOfYear)$(Rev:.r) # No PR-based triggers. pr: none -# We trigger runs on activity on the internal/main branch, but not on any other branches. This +# We trigger runs on activity on the internal/release/7.0 branch, but not on any other branches. This # pipeline is intended to only run against the ADO.Net dotnet-sqlclient repo. trigger: branches: include: - - internal/main + - internal/release/7.0 -# We run this pipeline on a daily schedule. +# We run this pipeline weekly on Wednesdays at 03:00 UTC. schedules: - - cron: "30 4 * * *" - displayName: Daily 04:30 UTC Build + - cron: "0 3 * * Wed" + displayName: Weekly Wednesday 03:00 UTC Build branches: include: - - internal/main + - internal/release/7.0 always: true # These parameters are visible in the Azure DevOps pipeline UI when a new run is queued. diff --git a/eng/pipelines/onebranch/variables/common-variables.yml b/eng/pipelines/onebranch/variables/common-variables.yml index 35236f7ff6..b4d0ca470b 100644 --- a/eng/pipelines/onebranch/variables/common-variables.yml +++ b/eng/pipelines/onebranch/variables/common-variables.yml @@ -66,15 +66,15 @@ variables: # The NuGet package version for GA releases (non-preview). - name: abstractionsPackageVersion - value: '1.0.0' + value: '7.0.2' # The NuGet package version for preview releases. - name: abstractionsPackagePreviewVersion - value: 1.0.0-preview1.$(Build.BuildNumber) + value: 7.0.2-preview1.$(Build.BuildNumber) # The AssemblyFileVersion for all assemblies in the Abstractions package. - name: abstractionsAssemblyFileVersion - value: 1.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # ---------------------------------------------------------------------------- # MDS Package Versions @@ -84,7 +84,7 @@ variables: # The NuGet package version for GA releases (non-preview). - name: mdsPackageVersion - value: '7.0.1' + value: '7.0.2' # The NuGet package version for preview releases. # @@ -97,7 +97,7 @@ variables: # The AssemblyFileVersion for all assemblies in the MDS package. - name: mdsAssemblyFileVersion - value: 7.0.1.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # The path to the NuGet packaging specification file used to generate the MDS NuGet package. - name: nuspecPath @@ -111,15 +111,15 @@ variables: # The NuGet package version for GA releases (non-preview). - name: loggingPackageVersion - value: '1.0.0' + value: '7.0.2' # The NuGet package version for preview releases. - name: loggingPackagePreviewVersion - value: 1.0.0-preview1.$(Build.BuildNumber) + value: 7.0.2-preview1.$(Build.BuildNumber) # The AssemblyFileVersion for all assemblies in the Logging package. - name: loggingAssemblyFileVersion - value: 1.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # ---------------------------------------------------------------------------- # Azure Package Versions @@ -129,15 +129,15 @@ variables: # The NuGet package version for GA releases (non-preview). - name: azurePackageVersion - value: '1.0.0' + value: '7.0.2' # The NuGet package version for preview releases. - name: azurePackagePreviewVersion - value: 1.0.0-preview1.$(Build.BuildNumber) + value: 7.0.2-preview1.$(Build.BuildNumber) # The AssemblyFileVersion for all assemblies in the Azure package. - name: azureAssemblyFileVersion - value: 1.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # ---------------------------------------------------------------------------- # SqlServer.Server Package Versions @@ -165,15 +165,15 @@ variables: # The NuGet package version for GA releases (non-preview). - name: akvPackageVersion - value: '7.0.0' + value: '7.0.2' # The NuGet package version for preview releases. - name: akvPackagePreviewVersion - value: 7.0.0-preview1.$(Build.BuildNumber) + value: 7.0.2-preview1.$(Build.BuildNumber) # The AssemblyFileVersion for all assemblies in the AKV Provider package. - name: akvAssemblyFileVersion - value: 7.0.0.$(assemblyBuildNumber) + value: 7.0.2.$(assemblyBuildNumber) # ---------------------------------------------------------------------------- # MDS Symbols Publishing diff --git a/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml b/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml index 35ec3a15cd..a6646d12af 100644 --- a/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml +++ b/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml @@ -48,6 +48,7 @@ pr: - global.json - NuGet.config exclude: + - eng/pipelines/kerberos/* - eng/pipelines/onebranch/* # Do not trigger commit or schedule runs for this pipeline. diff --git a/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml b/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml index c12fcee659..5069bbea5e 100644 --- a/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml +++ b/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml @@ -48,6 +48,7 @@ pr: - global.json - NuGet.config exclude: + - eng/pipelines/kerberos/* - eng/pipelines/onebranch/* # Do not trigger commit or schedule runs for this pipeline. diff --git a/eng/pipelines/stages/build-abstractions-package-ci-stage.yml b/eng/pipelines/stages/build-abstractions-package-ci-stage.yml index 6aae1703b8..8579b79caf 100644 --- a/eng/pipelines/stages/build-abstractions-package-ci-stage.yml +++ b/eng/pipelines/stages/build-abstractions-package-ci-stage.yml @@ -67,6 +67,30 @@ parameters: - detailed - diagnostic + # The name of the Logging pipeline artifacts to download. + # + # This is used when the referenceType is 'Package'. + - name: loggingArtifactsName + type: string + default: Logging.Artifacts + + # The Logging package version to depend on. + # + # This is used when the referenceType is 'Package'. + - name: loggingPackageVersion + type: string + default: '' + + # The C# project reference type to use when building and packing the packages. + - name: referenceType + type: string + default: Project + values: + # Reference sibling packages as NuGet packages. + - Package + # Reference sibling packages as C# projects. + - Project + stages: - stage: build_abstractions_package_stage @@ -141,3 +165,6 @@ stages: - test_abstractions_package_job_windows - test_abstractions_package_job_macos dotnetVerbosity: ${{ parameters.dotnetVerbosity }} + loggingArtifactsName: ${{ parameters.loggingArtifactsName }} + loggingPackageVersion: ${{ parameters.loggingPackageVersion }} + referenceType: ${{ parameters.referenceType }} diff --git a/eng/pipelines/stages/build-azure-package-ci-stage.yml b/eng/pipelines/stages/build-azure-package-ci-stage.yml index 3b57ce8c3b..36d754b15f 100644 --- a/eng/pipelines/stages/build-azure-package-ci-stage.yml +++ b/eng/pipelines/stages/build-azure-package-ci-stage.yml @@ -101,6 +101,20 @@ parameters: - detailed - diagnostic + # The name of the Logging pipeline artifacts to download. + # + # This is used when the referenceType is 'Package'. + - name: loggingArtifactsName + type: string + default: Logging.Artifacts + + # The Logging package version to depend on. + # + # This is used when the referenceType is 'Package'. + - name: loggingPackageVersion + type: string + default: '' + # The name of the MDS pipeline artifacts to download. # # This is used when the referenceType is 'Package'. @@ -282,4 +296,6 @@ stages: - test_azure_package_job_windows_integration - test_azure_package_job_macos dotnetVerbosity: ${{ parameters.dotnetVerbosity }} + loggingArtifactsName: ${{ parameters.loggingArtifactsName }} + loggingPackageVersion: ${{ parameters.loggingPackageVersion }} referenceType: ${{ parameters.referenceType }} diff --git a/eng/pipelines/steps/install-dotnet.yml b/eng/pipelines/steps/install-dotnet.yml index e6a50ee03f..5774984663 100644 --- a/eng/pipelines/steps/install-dotnet.yml +++ b/eng/pipelines/steps/install-dotnet.yml @@ -55,8 +55,12 @@ steps: - ${{ if ne(parameters.architecture, 'arm64') }}: # Install the SDK listed in the global.json file. + # + # retryCountOnTaskFailure is set because UseDotNet@2 fails intermittently + # in CI due to transient network/CDN issues when downloading the SDK. - task: UseDotNet@2 displayName: Install .NET SDK (global.json) + retryCountOnTaskFailure: 3 inputs: installationPath: ${{ parameters.installDir }} packageType: sdk @@ -69,6 +73,7 @@ steps: - ${{ each version in parameters.runtimes }}: - task: UseDotNet@2 displayName: Install .NET ${{ version }} Runtime + retryCountOnTaskFailure: 3 inputs: installationPath: ${{ parameters.installDir }} packageType: runtime @@ -81,8 +86,13 @@ steps: - ${{ else }}: # Use the install script for ARM64. + # + # retryCountOnTaskFailure provides an outer retry around the script's + # internal retry loop, in case the script download itself or the entire + # step fails due to transient issues. - task: PowerShell@2 displayName: Install .NET SDK and Runtimes for ARM64 + retryCountOnTaskFailure: 3 inputs: targetType: filePath pwsh: true diff --git a/global.json b/global.json index 48cbbf1a80..3c51141e58 100644 --- a/global.json +++ b/global.json @@ -4,13 +4,14 @@ // We currently require the .NET 10 SDK to build and test the project. // // GOTCHA: Our CI infrastructure for Windows uses VS 2022 which comes with MSBuild 17.x. The - // .NET 10 SDK versions in the 10.0.2xx series require MSBuild 18.x, so we specify the most - // recent 10.0.1xx series release which is compatible with MSBuild 17.x. + // .NET 10 SDK versions in the 10.0.2xx series require MSBuild 18.x, so we stick with the + // 10.0.1xx feature band which is compatible with MSBuild 17.x. // - "version": "10.0.107", + "version": "10.0.109", - // We cannot allow any roll forward due to the above MSBuild compatibility issues. - "rollForward": "disable", + // Allow roll-forward within the 10.0.1xx feature band so servicing patches + // are picked up automatically without requiring a PR for each bump. + "rollForward": "patch", // Do not allow pre-release versions. // diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AbstractionsVersions.props b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AbstractionsVersions.props index 2bd328fb92..5ebc995a32 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AbstractionsVersions.props +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AbstractionsVersions.props @@ -41,7 +41,7 @@ --> - 1 + 7 <_OurPackageVersion Condition="'$(AbstractionsPackageVersion)' != ''">$(AbstractionsPackageVersion) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs index e0adf34e49..5007384465 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs @@ -59,7 +59,7 @@ static Internal() // Look for the manager class. const string className = "Microsoft.Data.SqlClient.SqlAuthenticationProviderManager"; - var manager = assembly.GetType(className); + Type? manager = assembly.GetType(className); if (manager is null) { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 84b6343497..2b58a8676f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -1,4 +1,4 @@ - + - 1 + 7 <_OurPackageVersion Condition="'$(AzurePackageVersion)' != ''">$(AzurePackageVersion) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs new file mode 100644 index 0000000000..af255844fc --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs @@ -0,0 +1,42 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.Data.SqlClient; + +/// +/// Win32 P/Invoke wrappers used by for +/// console-window discovery. Follows the .NET runtime's Interop convention: one Win32 +/// import per file, grouped into a nested Interop.<module> static class that mirrors +/// the Win32 DLL it targets. Only the internal helper is exposed; the raw +/// DllImport stays private. +/// +internal static partial class Interop +{ + internal static partial class User32 + { + /// + /// GA_ROOTOWNER flag value for GetAncestor — "Retrieves the owned root + /// window by walking the chain of parent and owner windows returned by GetParent." + /// + private const uint GA_ROOTOWNER = 3; + + /// + /// Raw user32!GetAncestor P/Invoke. Documented by Windows to return + /// rather than throw when the input handle is invalid. + /// + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + + /// + /// Walks the parent/owner chain of and returns the root owner + /// window, or when none can be found. + /// + internal static IntPtr GetRootOwner(IntPtr hwnd) + { + return GetAncestor(hwnd, GA_ROOTOWNER); + } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs new file mode 100644 index 0000000000..4638e1458d --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs @@ -0,0 +1,27 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.Data.SqlClient; + +/// +/// Win32 P/Invoke wrappers used by for +/// console-window discovery. Follows the .NET runtime's Interop convention: one Win32 +/// import per file, grouped into a nested Interop.<module> static class that mirrors +/// the Win32 DLL it targets. +/// +internal static partial class Interop +{ + internal static partial class Kernel32 + { + /// + /// Raw kernel32!GetConsoleWindow P/Invoke. Documented by Windows to return + /// when the calling process is not attached to a console + /// (and to never throw). + /// + [DllImport("kernel32.dll")] + internal static extern IntPtr GetConsoleWindow(); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs index 5c7572ec84..e90dcce506 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs @@ -10,6 +10,7 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; // These tests were moved from MDS FunctionalTests AADAuthenticationTests.cs. +[Collection("SqlAuthenticationProvider")] public class AADAuthenticationTests { [Fact] diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs index 8c4006c5b4..5dd3ac336e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs @@ -26,120 +26,6 @@ public static void KustoDatabaseTest() Assert.Equal(System.Data.ConnectionState.Open, connection.State); } - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void AADPasswordWithWrongPassword() - { - string[] credKeys = { "Password", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, credKeys) + "Password=TestPassword;"; - - Assert.Throws(() => ConnectAndDisconnect(connStr)); - - // We cannot verify error message with certainty as driver may cache token from other tests for current user - // and error message may change accordingly. - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void TestADPasswordAuthentication() - { - // Connect to Azure DB with password and retrieve user name. - using (SqlConnection conn = new SqlConnection(Config.PasswordConnectionString)) - { - conn.Open(); - using (SqlCommand sqlCommand = new SqlCommand - ( - cmdText: $"SELECT SUSER_SNAME();", - connection: conn, - transaction: null - )) - { - string customerId = (string)sqlCommand.ExecuteScalar(); - string expected = RetrieveValueFromConnStr(Config.PasswordConnectionString, new string[] { "User ID", "UID" }); - Assert.Equal(expected, customerId); - } - } - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void EmptyPasswordInConnStrAADPassword() - { - // connection fails with expected error message. - string[] pwdKey = { "Password", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, pwdKey) + "Password=;"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string? user = FetchKeyInConnStr(Config.PasswordConnectionString, new string[] { "User Id", "UID" }); - string expectedMessage = string.Format("Failed to authenticate the user {0} in Active Directory (Authentication=ActiveDirectoryPassword).", user); - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.OnWindows), - nameof(Config.HasPasswordConnectionString))] - public static void EmptyCredInConnStrAADPassword() - { - // connection fails with expected error message. - string[] removeKeys = { "User ID", "Password", "UID", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, removeKeys) + "User ID=; Password=;"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string expectedMessage = "Failed to authenticate the user in Active Directory (Authentication=ActiveDirectoryPassword)."; - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.OnUnix), - nameof(Config.HasPasswordConnectionString))] - public static void EmptyCredInConnStrAADPasswordAnyUnix() - { - // connection fails with expected error message. - string[] removeKeys = { "User ID", "Password", "UID", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, removeKeys) + "User ID=; Password=;"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string expectedMessage = "MSAL cannot determine the username (UPN) of the currently logged in user.For Integrated Windows Authentication and Username/Password flows, please use .WithUsername() before calling ExecuteAsync()."; - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void AADPasswordWithInvalidUser() - { - // connection fails with expected error message. - string[] removeKeys = { "User ID", "UID" }; - string user = "testdotnet@domain.com"; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, removeKeys) + $"User ID={user}"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string expectedMessage = string.Format("Failed to authenticate the user {0} in Active Directory (Authentication=ActiveDirectoryPassword).", user); - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void NoCredentialsActiveDirectoryPassword() - { - // test Passes with correct connection string. - ConnectAndDisconnect(Config.PasswordConnectionString); - - // connection fails with expected error message. - string[] credKeys = { "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = RemoveKeysInConnStr(Config.PasswordConnectionString, credKeys); - InvalidOperationException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); - - string expectedMessage = "Either Credential or both 'User ID' and 'Password' (or 'UID' and 'PWD') connection string keywords must be specified, if 'Authentication=Active Directory Password'."; - Assert.Contains(expectedMessage, e.Message); - } - [ConditionalFact( typeof(Config), nameof(Config.HasPasswordConnectionString), diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs index f153096d9f..1fe30b979e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs @@ -40,6 +40,7 @@ internal static class Config internal static bool DebugEmit { get; } = false; internal static bool IntegratedSecuritySupported { get; } = false; internal static bool ManagedIdentitySupported { get; } = false; + // @TODO Remove PasswordConnectionString from config; AAD Password auth is deprecated internal static string PasswordConnectionString { get; } = string.Empty; internal static string ServicePrincipalId { get; } = string.Empty; internal static string ServicePrincipalSecret { get; } = string.Empty; diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs index 84d32651d5..fa426141c9 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs @@ -4,6 +4,7 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; +[Collection("SqlAuthenticationProvider")] public class DefaultAuthProviderTests { // Verify that our auth provider has been installed for all AAD/Entra diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs new file mode 100644 index 0000000000..2bf23f7873 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs @@ -0,0 +1,14 @@ +// 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. + +namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; + +/// +/// Defines a test collection that serializes execution of test classes +/// which mutate the global registry. +/// +[CollectionDefinition("SqlAuthenticationProvider")] +public class SqlAuthenticationProviderCollection +{ +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs new file mode 100644 index 0000000000..9b2aa5dcf7 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -0,0 +1,316 @@ +// 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.Reflection; + +namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; + +[Collection("SqlAuthenticationProvider")] +public class WamBrokerTests +{ + // The SqlClient first-party application client id that is hard-coded in the provider. + private const string SqlClientApplicationId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; + + // A fixed, deterministic stand-in for a caller-supplied application id. Hard-coded (instead + // of Guid.NewGuid()) so test outcomes don't depend on RNG and so a single point asserts + // that this value differs from the SqlClient first-party id. + private const string TestCustomAppId = "11111111-2222-3333-4444-555555555555"; + + // Reads the private _parentActivityOrWindowFunc field. Used to assert downstream effects + // of SetParentActivityOrWindowFunc without triggering a live MSAL flow. + private static Func? GetParentActivityOrWindowFunc(ActiveDirectoryAuthenticationProvider provider) + { + FieldInfo? field = typeof(ActiveDirectoryAuthenticationProvider).GetField( + "_parentActivityOrWindowFunc", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return (Func?)field!.GetValue(provider); + } + + /// + /// A callback is treated as "clear any previously installed callback" + /// and must not throw. This is a deliberate API contract change from the original + /// behavior so callers can opt out without recreating + /// the provider. Asserts the underlying field is reset to so the + /// provider's downstream consumer (MSAL parameters builder) sees the cleared state. + /// + [Fact] + public void SetParentActivityOrWindowFunc_Null_ClearsCallback() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + Func first = () => IntPtr.Zero; + provider.SetParentActivityOrWindowFunc(first); + Assert.Same(first, GetParentActivityOrWindowFunc(provider)); + + provider.SetParentActivityOrWindowFunc(null); + Assert.Null(GetParentActivityOrWindowFunc(provider)); + } + + /// + /// The constructor uses the SqlClient first-party application id, which always + /// enables WAM broker mode regardless of any opt-in flag. + /// + [Fact] + public void Ctor_ApplicationClientId_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Constructor with SqlClient first-party application id must enable WAM broker."); + } + + /// + /// The parameterless constructor uses the SqlClient first-party application id, which always + /// enables WAM broker mode regardless of any opt-in flag. + /// + [Fact] + public void Ctor_Default_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Default ctor must enable WAM broker (uses SqlClient first-party application id)."); + } + + /// A caller-supplied application id without explicit opt-in must NOT enable WAM broker. + [Fact] + public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider(TestCustomAppId); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker, + "Custom application id without useWamBroker=true must keep WAM broker disabled."); + } + + /// + /// Mirrors the previous test for the + /// constructor: a caller (or app.config) that sets only ApplicationClientId and skips + /// UseWamBroker must get the documented default of . This is + /// the contract SqlAuthenticationProviderManager relies on when reflecting onto the + /// Options ctor and only forwarding the properties that were explicitly configured. + /// + [Fact] + public void Ctor_Options_AppClientIdOnly_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + // UseWamBroker intentionally left at its default (false). + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker, + "Options ctor with ApplicationClientId set and UseWamBroker omitted must keep WAM broker disabled."); + } + + /// + /// Passing the SqlClient first-party application id to the single-string constructor must + /// enable WAM broker. The first-party app id is hard-wired to the WAM broker redirect URI, + /// so callers that opt into it explicitly should get the same behavior as the parameterless + /// constructor. + /// + [Fact] + public void Ctor_AppClientId_SqlClientId_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId); + + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Single-string ctor with the SqlClient first-party id must enable WAM broker."); + } + + /// A caller-supplied application id with explicit opt-in must enable WAM broker. + [Fact] + public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Custom application id with UseWamBroker=true must enable WAM broker."); + } + + /// A caller-supplied application id with explicit opt-out keeps WAM broker disabled. + [Fact] + public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = false, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker); + } + + /// + /// Even when the SqlClient first-party application id is passed explicitly with + /// UseWamBroker=false, WAM broker mode must remain enabled because the first-party + /// app id is hard-wired to the WAM broker redirect URI. This guards the OR-condition in + /// the provider's constructor. + /// + [Fact] + public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = SqlClientApplicationId, + UseWamBroker = false, + }); + + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "SqlClient first-party application id must always enable WAM broker, regardless of the UseWamBroker option."); + } + + /// + /// Passing a device-code callback together with a custom application id and + /// UseWamBroker=true via + /// must enable WAM broker mode. + /// + [Fact] + public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + DeviceCodeFlowCallback = static _ => Task.CompletedTask, + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker); + } + + /// + /// The two-arg device-code constructor (deviceCodeCallback, applicationClientId) must default + /// useWamBroker to for caller-supplied application ids. + /// + [Fact] + public void Ctor_WithDeviceCodeCallback_AppClientIdOnly_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider( + deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask, + applicationClientId: TestCustomAppId); + + Assert.False(provider.UseWamBroker); + Assert.NotEqual(SqlClientApplicationId, provider.ApplicationClientId); + } + + /// + /// When the device-code callback constructor is invoked without an application id, the + /// provider falls back to the SqlClient first-party id and must enable WAM broker. + /// + [Fact] + public void Ctor_WithDeviceCodeCallback_NoAppClientId_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask); + + Assert.True(provider.UseWamBroker); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + } + + /// + /// The -based constructor + /// is the recommended overload for new code. It must honor + /// the same way the positional-argument overloads do. + /// + [Fact] + public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker); + } + + /// + /// Options with ApplicationClientId = null falls back to the SqlClient first-party + /// id, which always enables WAM broker, regardless of UseWamBroker. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Ctor_Options_NullAppId_AlwaysEnablesWamBroker(bool useWamBroker) + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = null, + UseWamBroker = useWamBroker, + }); + + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker); + } + + /// + /// The Options-based constructor must reject a options instance with + /// so misuse fails fast at construction. + /// + [Fact] + public void Ctor_Options_Null_ThrowsArgumentNullException() + { + Assert.Throws( + () => new ActiveDirectoryAuthenticationProvider((ActiveDirectoryAuthenticationProviderOptions)null!)); + } + + /// + /// Registering an instance via must not + /// wrap or replace the instance, so its WAM broker setting survives registration. + /// + /// + /// Provider registration mutates global state shared across this test class collection + /// (and any other test that depends on the default provider being installed). Save and + /// restore the original provider in a finally block to keep cross-test isolation. + /// + [Fact] + public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + SqlAuthenticationProvider? original = + SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive); + try + { + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + + var retrieved = SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive) + as ActiveDirectoryAuthenticationProvider; + Assert.NotNull(retrieved); + Assert.Same(provider, retrieved); + Assert.Equal(TestCustomAppId, retrieved!.ApplicationClientId); + Assert.True(retrieved.UseWamBroker); + } + finally + { + if (original is not null) + { + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, original); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient.Internal/Logging/src/LoggingVersions.props b/src/Microsoft.Data.SqlClient.Internal/Logging/src/LoggingVersions.props index 597a692457..c48f8861e5 100644 --- a/src/Microsoft.Data.SqlClient.Internal/Logging/src/LoggingVersions.props +++ b/src/Microsoft.Data.SqlClient.Internal/Logging/src/LoggingVersions.props @@ -41,7 +41,7 @@ --> - 1 + 7 <_OurPackageVersion Condition="'$(LoggingPackageVersion)' != ''">$(LoggingPackageVersion) diff --git a/src/Microsoft.Data.SqlClient.sln b/src/Microsoft.Data.SqlClient.sln index 0a80e8f0ca..9df63c8ad3 100644 --- a/src/Microsoft.Data.SqlClient.sln +++ b/src/Microsoft.Data.SqlClient.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.11911.148 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Data.SqlClient", "Microsoft.Data.SqlClient\netfx\src\Microsoft.Data.SqlClient.csproj", "{407890AC-9876-4FEF-A6F1-F36A876BAADE}" EndProject @@ -262,7 +262,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "steps", "steps", "{EABE3A3E ..\eng\pipelines\common\templates\steps\configure-sql-server-win-step.yml = ..\eng\pipelines\common\templates\steps\configure-sql-server-win-step.yml ..\eng\pipelines\common\templates\steps\copy-dlls-for-test-step.yml = ..\eng\pipelines\common\templates\steps\copy-dlls-for-test-step.yml ..\eng\pipelines\common\templates\steps\esrp-code-signing-step.yml = ..\eng\pipelines\common\templates\steps\esrp-code-signing-step.yml - ..\eng\pipelines\common\templates\steps\generate-nuget-package-step.yml = ..\eng\pipelines\common\templates\steps\generate-nuget-package-step.yml ..\eng\pipelines\common\templates\steps\override-sni-version.yml = ..\eng\pipelines\common\templates\steps\override-sni-version.yml ..\eng\pipelines\common\templates\steps\pre-build-step.yml = ..\eng\pipelines\common\templates\steps\pre-build-step.yml ..\eng\pipelines\common\templates\steps\prepare-test-db-step.yml = ..\eng\pipelines\common\templates\steps\prepare-test-db-step.yml @@ -308,8 +307,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "steps", "steps", "{AD738BD4 ..\eng\pipelines\steps\compound-extract-akv-apiscan-files-step.yml = ..\eng\pipelines\steps\compound-extract-akv-apiscan-files-step.yml ..\eng\pipelines\steps\compound-nuget-pack-step.yml = ..\eng\pipelines\steps\compound-nuget-pack-step.yml ..\eng\pipelines\steps\compound-publish-symbols-step.yml = ..\eng\pipelines\steps\compound-publish-symbols-step.yml - ..\eng\pipelines\steps\install-dotnet.yml = ..\eng\pipelines\steps\install-dotnet.yml ..\eng\pipelines\steps\install-dotnet-arm64.ps1 = ..\eng\pipelines\steps\install-dotnet-arm64.ps1 + ..\eng\pipelines\steps\install-dotnet.yml = ..\eng\pipelines\steps\install-dotnet.yml ..\eng\pipelines\steps\roslyn-analyzers-akv-step.yml = ..\eng\pipelines\steps\roslyn-analyzers-akv-step.yml ..\eng\pipelines\steps\script-output-environment-variables-step.yml = ..\eng\pipelines\steps\script-output-environment-variables-step.yml EndProjectSection @@ -391,6 +390,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.GenAPI", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Cci.Extensions", "..\tools\GenAPI\Microsoft.Cci.Extensions\Microsoft.Cci.Extensions.csproj", "{2F900B8A-EA19-4274-9AE9-3E6A59856FCC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "apps", "apps", "{CCD7F253-A1EF-462D-A8CE-FBF04AB9EA0C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSqlConnector", "..\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj", "{8B299DCD-C728-231A-747E-254252866A0F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -839,6 +842,18 @@ Global {2F900B8A-EA19-4274-9AE9-3E6A59856FCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {2F900B8A-EA19-4274-9AE9-3E6A59856FCC}.Release|x64.ActiveCfg = Release|Any CPU {2F900B8A-EA19-4274-9AE9-3E6A59856FCC}.Release|x86.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x64.ActiveCfg = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x64.Build.0 = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x86.Build.0 = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|Any CPU.Build.0 = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x64.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x64.Build.0 = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x86.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -914,10 +929,13 @@ Global {020A7E7B-04C9-4326-985F-045B42CC2200} = {7289C27E-D7DF-2C71-84B4-151F3A162493} {D433ED2D-5E47-4A4B-B94A-EC71482715C7} = {020A7E7B-04C9-4326-985F-045B42CC2200} {AA77C107-9A78-4A99-98BB-21FF7A1E0B01} = {2B71F605-037E-5629-6E23-0FA3C297446D} + {C3FE67C1-D288-45ED-A35C-08107396F8BB} = {CCD7F253-A1EF-462D-A8CE-FBF04AB9EA0C} {351BE847-A0BF-450C-A5BC-8337AFA49EAA} = {7289C27E-D7DF-2C71-84B4-151F3A162493} {1DB299CE-95EA-4566-84DD-171768758291} = {351BE847-A0BF-450C-A5BC-8337AFA49EAA} {583E2B51-A90B-4F34-AD8B-4061504E855E} = {351BE847-A0BF-450C-A5BC-8337AFA49EAA} {2F900B8A-EA19-4274-9AE9-3E6A59856FCC} = {351BE847-A0BF-450C-A5BC-8337AFA49EAA} + {CCD7F253-A1EF-462D-A8CE-FBF04AB9EA0C} = {ED952CF7-84DF-437A-B066-F516E9BE1C2C} + {8B299DCD-C728-231A-747E-254252866A0F} = {CCD7F253-A1EF-462D-A8CE-FBF04AB9EA0C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {01D48116-37A2-4D33-B9EC-94793C702431} 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 7145dbac28..47bffc2948 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -15,7 +15,7 @@ true Core $(BaseProduct) true - $(NoWarn);IL2026;IL2057;IL2072;IL2075 + $(NoWarn);IL2026;IL2057;IL2067;IL2070;IL2072;IL2075 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 cf0a296b30..c9b069cd21 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 @@ -1354,6 +1354,11 @@ internal void OnFeatureExtAck(int featureId, byte[] data) len = bLen; } + if (len < 0 || len > data.Length - i) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, len); + } + byte[] stateData = new byte[len]; Buffer.BlockCopy(data, i, stateData, 0, len); i += len; @@ -2233,7 +2238,6 @@ private void AttemptOneLogin( // @TODO: Rename to meet naming conventions private bool AttemptRetryADAuthWithTimeoutError( SqlException sqlex, - SqlConnectionString connectionOptions, // @TODO: this is not used TimeoutTimer timeout) { if (!_activeDirectoryAuthTimeoutRetryHelper.CanRetryWithSqlException(sqlex)) @@ -2243,8 +2247,10 @@ private bool AttemptRetryADAuthWithTimeoutError( // Reset client-side timeout. timeout.Reset(); - // When server timeout, the auth context key was already created. Clean it up here. + // Clear fed-auth state captured by the failed attempt so OnFedAuthInfo's cache-reuse branch starts from null on the retry. _dbConnectionPoolAuthenticationContextKey = null; + _fedAuthToken = null; + _newDbConnectionPoolAuthenticationContext = null; // When server timeouts, connection is doomed. Reset here to allow reconnection. UnDoomThisConnection(); @@ -3343,7 +3349,7 @@ private void LoginNoFailover( } catch (SqlException sqlex) { - if (AttemptRetryADAuthWithTimeoutError(sqlex, connectionOptions, timeout)) + if (AttemptRetryADAuthWithTimeoutError(sqlex, timeout)) { continue; } @@ -3657,7 +3663,7 @@ private void LoginWithFailover( } catch (SqlException sqlex) { - if (AttemptRetryADAuthWithTimeoutError(sqlex, connectionOptions, timeout)) + if (AttemptRetryADAuthWithTimeoutError(sqlex, timeout)) { continue; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SignatureVerificationCache.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SignatureVerificationCache.cs index 8d1ce98df1..dc086d4610 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SignatureVerificationCache.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SignatureVerificationCache.cs @@ -9,6 +9,29 @@ namespace Microsoft.Data.SqlClient { + /// + /// Tri-state result returned by . + /// Distinguishes a cache miss from a cached negative result so callers cannot conflate the two. + /// + internal enum SignatureVerificationResult + { + /// + /// No cached entry exists for the requested CMK metadata. + /// The caller must verify the signature with the key store provider. + /// + NotFound, + + /// + /// A cached entry exists and indicates that signature verification previously failed. + /// + False, + + /// + /// A cached entry exists and indicates that signature verification previously succeeded. + /// + True, + } + /// /// Cache for storing result of signature verification of CMK Metadata /// @@ -16,138 +39,159 @@ internal class ColumnMasterKeyMetadataSignatureVerificationCache { private const int CacheSize = 2000; // Cache size in number of entries. private const int CacheTrimThreshold = 300; // Threshold above the cache size when we start trimming. - - private const string _className = "ColumnMasterKeyMetadataSignatureVerificationCache"; - private const string _getSignatureVerificationResultMethodName = "GetSignatureVerificationResult"; - private const string _addSignatureVerificationResultMethodName = "AddSignatureVerificationResult"; - private const string _masterkeypathArgumentName = "masterKeyPath"; - private const string _keyStoreNameArgumentName = "keyStoreName"; - private const string _signatureName = "signature"; private const string _cacheLookupKeySeparator = ":"; - private static readonly ColumnMasterKeyMetadataSignatureVerificationCache _signatureVerificationCache = new ColumnMasterKeyMetadataSignatureVerificationCache(); private static readonly TimeSpan s_verificationCacheTimeout = TimeSpan.FromDays(10); - //singleton instance - internal static ColumnMasterKeyMetadataSignatureVerificationCache Instance { get { return _signatureVerificationCache; } } + /// + /// Gets the process-wide singleton instance of the signature verification cache. + /// + internal static ColumnMasterKeyMetadataSignatureVerificationCache Instance { get; } = new(); private readonly MemoryCache _cache; - private int _inTrim = 0; + private int _inTrim; private ColumnMasterKeyMetadataSignatureVerificationCache() { _cache = new MemoryCache(new MemoryCacheOptions()); - _inTrim = 0; } /// - /// Get signature verification result for given CMK metadata (KeystoreName, MasterKeyPath, allowEnclaveComputations) and a given signature + /// Get signature verification result for given CMK metadata + /// (KeystoreName, MasterKeyPath, allowEnclaveComputations) and a given signature /// /// Key Store name for CMK /// Key Path for CMK /// boolean indicating whether the key can be sent to enclave /// Signature for the CMK metadata - internal bool GetSignatureVerificationResult(string keyStoreName, string masterKeyPath, bool allowEnclaveComputations, byte[] signature) + /// Tri-state result indicating whether signature verification succeeded, failed, or was not found in cache + /// + /// Thrown when , , + /// or is . + /// + /// + /// Thrown when or + /// is empty or whitespace, or when has length zero. + /// + internal SignatureVerificationResult GetSignatureVerificationResult(string keyStoreName, string masterKeyPath, bool allowEnclaveComputations, byte[] signature) { - ValidateStringArgumentNotNullOrEmpty(masterKeyPath, _masterkeypathArgumentName, _getSignatureVerificationResultMethodName); - ValidateStringArgumentNotNullOrEmpty(keyStoreName, _keyStoreNameArgumentName, _getSignatureVerificationResultMethodName); - ValidateSignatureNotNullOrEmpty(signature, _getSignatureVerificationResultMethodName); + ValidateStringArgumentNotNullOrEmpty(masterKeyPath, nameof(masterKeyPath), nameof(GetSignatureVerificationResult)); + ValidateStringArgumentNotNullOrEmpty(keyStoreName, nameof(keyStoreName), nameof(GetSignatureVerificationResult)); + ValidateSignatureNotNullOrEmpty(signature, nameof(GetSignatureVerificationResult)); string cacheLookupKey = GetCacheLookupKey(masterKeyPath, allowEnclaveComputations, signature, keyStoreName); - return _cache.TryGetValue(cacheLookupKey, out bool value); + if (!_cache.TryGetValue(cacheLookupKey, out bool value)) + { + return SignatureVerificationResult.NotFound; + } + + return value ? SignatureVerificationResult.True : SignatureVerificationResult.False; } /// - /// Add signature verification result for given CMK metadata (KeystoreName, MasterKeyPath, allowEnclaveComputations) and a given signature in the cache + /// Add signature verification result for given CMK metadata (KeystoreName, + /// MasterKeyPath, allowEnclaveComputations) and a given signature in the cache /// /// Key Store name for CMK /// Key Path for CMK /// boolean indicating whether the key can be sent to enclave /// Signature for the CMK metadata /// result indicating signature verification success/failure + /// + /// Thrown when , , + /// or is . + /// + /// + /// Thrown when or is empty or whitespace, + /// or when has length zero. + /// internal void AddSignatureVerificationResult(string keyStoreName, string masterKeyPath, bool allowEnclaveComputations, byte[] signature, bool result) { - ValidateStringArgumentNotNullOrEmpty(masterKeyPath, _masterkeypathArgumentName, _addSignatureVerificationResultMethodName); - ValidateStringArgumentNotNullOrEmpty(keyStoreName, _keyStoreNameArgumentName, _addSignatureVerificationResultMethodName); - ValidateSignatureNotNullOrEmpty(signature, _addSignatureVerificationResultMethodName); + ValidateStringArgumentNotNullOrEmpty(masterKeyPath, nameof(masterKeyPath), nameof(AddSignatureVerificationResult)); + ValidateStringArgumentNotNullOrEmpty(keyStoreName, nameof(keyStoreName), nameof(AddSignatureVerificationResult)); + ValidateSignatureNotNullOrEmpty(signature, nameof(AddSignatureVerificationResult)); string cacheLookupKey = GetCacheLookupKey(masterKeyPath, allowEnclaveComputations, signature, keyStoreName); TrimCacheIfNeeded(); // By default evict after 10 days. - _cache.Set(cacheLookupKey, result, absoluteExpirationRelativeToNow: s_verificationCacheTimeout); + _cache.Set(cacheLookupKey, result, absoluteExpirationRelativeToNow: s_verificationCacheTimeout); } - private void ValidateSignatureNotNullOrEmpty(byte[] signature, string methodName) + private static void ValidateSignatureNotNullOrEmpty(byte[] signature, string methodName) { - if (signature == null || signature.Length == 0) + if (signature is null) + { + throw SQL.NullArgumentInternal(nameof(signature), nameof(ColumnMasterKeyMetadataSignatureVerificationCache), methodName); + } + if (signature.Length == 0) { - if (signature == null) - { - throw SQL.NullArgumentInternal(_signatureName, _className, methodName); - } - else - { - throw SQL.EmptyArgumentInternal(_signatureName, _className, methodName); - } + throw SQL.EmptyArgumentInternal(nameof(signature), nameof(ColumnMasterKeyMetadataSignatureVerificationCache), methodName); } } - private void ValidateStringArgumentNotNullOrEmpty(string stringArgValue, string stringArgName, string methodName) + private static void ValidateStringArgumentNotNullOrEmpty(string value, string argumentName, string methodName) { - if (string.IsNullOrWhiteSpace(stringArgValue)) + if (value is null) + { + throw SQL.NullArgumentInternal(argumentName, nameof(ColumnMasterKeyMetadataSignatureVerificationCache), methodName); + } + if (string.IsNullOrWhiteSpace(value)) { - if (stringArgValue == null) - { - throw SQL.NullArgumentInternal(stringArgName, _className, methodName); - } - else - { - throw SQL.EmptyArgumentInternal(stringArgName, _className, methodName); - } + throw SQL.EmptyArgumentInternal(argumentName, nameof(ColumnMasterKeyMetadataSignatureVerificationCache), methodName); } } + private void TrimCacheIfNeeded() { // If the size of the cache exceeds the threshold, set that we are in trimming and trim the cache accordingly. long currentCacheSize = _cache.Count; - if ((currentCacheSize > CacheSize + CacheTrimThreshold) && (0 == Interlocked.CompareExchange(ref _inTrim, 1, 0))) + if (currentCacheSize <= CacheSize + CacheTrimThreshold || Interlocked.CompareExchange(ref _inTrim, 1, 0) != 0) { - try - { - // Example: 2301 - 2000 = 301; 301 / 2301 = 0.1308 * 100 = 13% compacting - _cache.Compact((((double)(currentCacheSize - CacheSize) / (double)currentCacheSize) * 100)); - } - finally - { - // Reset _inTrim flag - Interlocked.CompareExchange(ref _inTrim, 0, 1); - } + return; + } + + try + { + // Example: 2301 - 2000 = 301; 301 / 2301 = 0.1308 * 100 = 13% compacting + _cache.Compact((double)(currentCacheSize - CacheSize) / currentCacheSize * 100); + } + finally + { + Interlocked.Exchange(ref _inTrim, 0); } } - private string GetCacheLookupKey(string masterKeyPath, bool allowEnclaveComputations, byte[] signature, string keyStoreName) + /// + /// Generates a cache key for the given CMK metadata and signature. The key is a + /// concatenation of the key store name, master key path, allowEnclaveComputations value, and signature, separated by a delimiter. + /// + /// The master key path. + /// Whether enclave computations are allowed. + /// The signature. + /// The key store name. + /// A string that can be used as a cache key. + private static string GetCacheLookupKey(string masterKeyPath, bool allowEnclaveComputations, byte[] signature, string keyStoreName) { - StringBuilder cacheLookupKeyBuilder = new StringBuilder(keyStoreName, - capacity: - keyStoreName.Length + - masterKeyPath.Length + - SqlSecurityUtility.GetBase64LengthFromByteLength(signature.Length) + - 3 /*separators*/ + - 10 /*boolean value + somebuffer*/); - - cacheLookupKeyBuilder.Append(_cacheLookupKeySeparator); - cacheLookupKeyBuilder.Append(masterKeyPath); - cacheLookupKeyBuilder.Append(_cacheLookupKeySeparator); - cacheLookupKeyBuilder.Append(allowEnclaveComputations); - cacheLookupKeyBuilder.Append(_cacheLookupKeySeparator); - cacheLookupKeyBuilder.Append(Convert.ToBase64String(signature)); - cacheLookupKeyBuilder.Append(_cacheLookupKeySeparator); - string cacheLookupKey = cacheLookupKeyBuilder.ToString(); - return cacheLookupKey; + int cacheCapacity = + keyStoreName.Length + + masterKeyPath.Length + + SqlSecurityUtility.GetBase64LengthFromByteLength(signature.Length) + + 4 * _cacheLookupKeySeparator.Length + + 10 /* boolean value + buffer */; + + return new StringBuilder(keyStoreName, capacity: cacheCapacity) + .Append(_cacheLookupKeySeparator) + .Append(masterKeyPath) + .Append(_cacheLookupKeySeparator) + .Append(allowEnclaveComputations) + .Append(_cacheLookupKeySeparator) + .Append(Convert.ToBase64String(signature)) + .Append(_cacheLookupKeySeparator) + .ToString(); } } } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index 54468782bd..4c77fbe8df 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -32,7 +32,7 @@ internal sealed class SqlAuthenticationProviderManager // The public key token of our Azure extension assembly, used to avoid loading imposter // assemblies. - private static readonly byte[] azurePublicKeyToken = [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ]; + private static readonly byte[] s_azurePublicKeyToken = [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ]; static SqlAuthenticationProviderManager() { @@ -70,10 +70,10 @@ static SqlAuthenticationProviderManager() nameof(SqlAuthenticationProviderManager) + $": Attempting to load Azure extension assembly={azureAssemblyName} with " + "expected public key token=" + - BitConverter.ToString(azurePublicKeyToken).Replace("-", "")); + BitConverter.ToString(s_azurePublicKeyToken).Replace("-", "")); var qualifiedName = new AssemblyName(azureAssemblyName); - qualifiedName.SetPublicKeyToken(azurePublicKeyToken); + qualifiedName.SetPublicKeyToken(s_azurePublicKeyToken); // The .NET Framework runtime will enforce the token during binding, causing Load() // to throw. This prevents an untrusted assembly from being loaded and having its @@ -92,7 +92,7 @@ static SqlAuthenticationProviderManager() { byte[]? actualToken = assembly.GetName().GetPublicKeyToken(); - if (actualToken is null || !actualToken.AsSpan().SequenceEqual(azurePublicKeyToken)) + if (actualToken is null || !actualToken.AsSpan().SequenceEqual(s_azurePublicKeyToken)) { SqlClientEventSource.Log.TryTraceEvent( nameof(SqlAuthenticationProviderManager) + @@ -132,7 +132,7 @@ static SqlAuthenticationProviderManager() // Look for the authentication provider class. const string className = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider"; - var type = assembly.GetType(className); + Type? type = assembly.GetType(className); if (type is null) { @@ -144,11 +144,28 @@ static SqlAuthenticationProviderManager() return; } - // Try to instantiate it. - var instance = Activator.CreateInstance( + // Try to instantiate it. Behavior depends on what the app + // configured in : + // * Neither applicationClientId nor useWamBroker -> use the + // parameterless constructor (defaults to the SqlClient + // first-party app id and enables WAM brokering on Windows). + // * applicationClientId only -> prefer the + // (ActiveDirectoryAuthenticationProviderOptions) constructor + // when the Azure extension exposes it; otherwise fall back + // to the legacy (string applicationClientId) constructor so + // older Azure extension versions keep working. + // * useWamBroker (with or without applicationClientId) -> + // requires the (Options) constructor because there is no + // positional analog. If the Azure extension is too old to + // expose Options, throw to surface the misconfiguration. + const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions"; + Type? optionsType = assembly.GetType(optionsTypeName); + + SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider( type, - [Instance._applicationClientId]) - as SqlAuthenticationProvider; + optionsType, + Instance._applicationClientId, + Instance._useWamBroker); if (instance is null) { @@ -188,17 +205,19 @@ static SqlAuthenticationProviderManager() // simply have no default and the app must provide one if they // attempt to use Active Directory authentication. catch (Exception ex) - when (ex is ArgumentNullException || - ex is ArgumentException || - ex is BadImageFormatException || - ex is FileLoadException || - ex is FileNotFoundException || - ex is MemberAccessException || - ex is MethodAccessException || - ex is MissingMethodException || - ex is NotSupportedException || - ex is TargetInvocationException || - ex is TypeLoadException) + when (ex is + AmbiguousMatchException or + ArgumentException or + BadImageFormatException or + FileLoadException or + FileNotFoundException or + MemberAccessException or + MethodAccessException or + MissingMethodException or + NotSupportedException or + TargetInvocationException or + TypeInitializationException or + TypeLoadException) { SqlClientEventSource.Log.TryTraceEvent( nameof(SqlAuthenticationProviderManager) + @@ -216,6 +235,13 @@ ex is TargetInvocationException || private readonly SqlClientLogger _sqlAuthLogger = new SqlClientLogger(); private readonly string? _applicationClientId = null; + // Optional override for ActiveDirectoryAuthenticationProviderOptions.UseWamBroker + // read from the app.config attribute. + // null means the app did not configure the value, in which case we leave the + // provider's default behavior (WAM is implied by the SqlClient first-party app id and + // off otherwise) untouched. + private readonly bool? _useWamBroker = null; + /// /// Constructor. /// @@ -239,6 +265,23 @@ private SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationS _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined Application Client Id found."); } + if (!string.IsNullOrEmpty(configSection.UseWamBroker)) + { + if (bool.TryParse(configSection.UseWamBroker, out bool useWamBroker)) + { + _useWamBroker = useWamBroker; + _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, $"Received user-defined UseWamBroker={useWamBroker}."); + } + else + { + _sqlAuthLogger.LogError(nameof(SqlAuthenticationProviderManager), methodName, $"Ignoring user-defined UseWamBroker='{configSection.UseWamBroker}': not a valid boolean."); + } + } + else + { + _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined UseWamBroker found."); + } + // Create user-defined auth initializer, if any. if (!string.IsNullOrEmpty(configSection.InitializerType)) { @@ -312,8 +355,81 @@ private SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationS /// Authentication provider or null if not found. internal static SqlAuthenticationProvider? GetProvider(SqlAuthenticationMethod authenticationMethod) { - SqlAuthenticationProvider? value; - return Instance._providers.TryGetValue(authenticationMethod, out value) ? value : null; + return Instance._providers.TryGetValue(authenticationMethod, out SqlAuthenticationProvider? value) ? value : null; + } + + // Reflectively constructs the Azure extension's ActiveDirectoryAuthenticationProvider, + // selecting the constructor that matches what the app configured. Extracted from the + // static initializer so it can be unit-tested with stub provider/options shapes. + // + // Returns null when no compatible constructor is available (e.g. a custom assembly + // that lacks both the (Options) and (string) ctors). + // + // Throws InvalidOperationException when useWamBroker is configured but the Azure + // extension is too old to expose ActiveDirectoryAuthenticationProviderOptions; that + // signals user-actionable misconfiguration and intentionally escapes the static ctor's + // catch-when filter so it surfaces as a TypeInitializationException. + internal static SqlAuthenticationProvider? CreateAzureAuthenticationProvider( + Type providerType, + Type? optionsType, + string? applicationClientId, + bool? useWamBroker) + { + if (applicationClientId is null && useWamBroker is null) + { + return Activator.CreateInstance(providerType) as SqlAuthenticationProvider; + } + + ConstructorInfo? optionsCtor = optionsType is null + ? null + : providerType.GetConstructor([optionsType]); + + if (useWamBroker is bool useWam) + { + if (optionsType is null || optionsCtor is null) + { + throw SQL.UseWamBrokerRequiresAzureExtensionUpgrade(); + } + + var options = Activator.CreateInstance(optionsType); + if (options is null) + { + return null; + } + + if (applicationClientId is not null) + { + optionsType.GetProperty("ApplicationClientId") + ?.SetValue(options, applicationClientId); + } + optionsType.GetProperty("UseWamBroker") + ?.SetValue(options, useWam); + + return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; + } + + // applicationClientId-only: prefer Options when the extension exposes it, + // otherwise fall back to the legacy (string) ctor for backward compatibility + // with older Azure extension versions. + if (optionsType is not null && optionsCtor is not null) + { + var options = Activator.CreateInstance(optionsType); + if (options is null) + { + return null; + } + optionsType.GetProperty("ApplicationClientId") + ?.SetValue(options, applicationClientId); + return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; + } + + ConstructorInfo? legacyCtor = providerType.GetConstructor([typeof(string)]); + if (legacyCtor is not null) + { + return legacyCtor.Invoke([applicationClientId]) as SqlAuthenticationProvider; + } + + return null; } /// @@ -448,6 +564,15 @@ internal class SqlAuthenticationProviderConfigurationSection : ConfigurationSect /// [ConfigurationProperty("applicationClientId", IsRequired = false)] public string ApplicationClientId => this["applicationClientId"] as string ?? string.Empty; + + /// + /// Forwarded to ActiveDirectoryAuthenticationProviderOptions.UseWamBroker + /// when the Azure extension's default provider is auto-installed. Stored as a string so + /// that an unset attribute can be distinguished from useWamBroker="false"; the + /// runtime parses it with . + /// + [ConfigurationProperty("useWamBroker", IsRequired = false)] + public string UseWamBroker => this["useWamBroker"] as string ?? string.Empty; } /// diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs index 71845ddeb9..d276f27738 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs @@ -1068,7 +1068,7 @@ public override void Cancel() "SqlCommand.Cancel | API | Correlation | " + $"Object Id {ObjectID}, " + $"Activity Id {ActivityCorrelator.Current}, " + - $"Client Connection Id {_activeConnection.ClientConnectionId}, " + + $"Client Connection Id {_activeConnection?.ClientConnectionId}, " + $"Command Text '{CommandText}'"); SqlStatistics statistics = null; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlDataReader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlDataReader.cs index c6b628bba7..f6d255e45a 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlDataReader.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlDataReader.cs @@ -2137,7 +2137,7 @@ override public long GetChars(int i, long dataIndex, char[] buffer, int bufferIn // if bad buffer index, throw if ((bufferIndex < 0) || (buffer != null && bufferIndex >= buffer.Length)) { - throw ADP.InvalidDestinationBufferIndex(buffer.Length, bufferIndex, nameof(bufferIndex)); + throw ADP.InvalidDestinationBufferIndex(buffer?.Length ?? 0, bufferIndex, nameof(bufferIndex)); } // if there is not enough room in the buffer for data diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlSecurityUtility.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlSecurityUtility.cs index ae20c27254..26e1a07594 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlSecurityUtility.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlSecurityUtility.cs @@ -331,18 +331,20 @@ internal static void VerifyColumnMasterKeySignature(string keyStoreName, string } else { - bool signatureVerificationResult = ColumnMasterKeyMetadataSignatureVerificationCache.GetSignatureVerificationResult(keyStoreName, keyPath, isEnclaveEnabled, CMKSignature); - if (signatureVerificationResult == false) - { - // We will simply bubble up the exception from VerifyColumnMasterKeyMetadata function. - isValidSignature = provider.VerifyColumnMasterKeyMetadata(keyPath, isEnclaveEnabled, - CMKSignature); + SignatureVerificationResult cachedResult = ColumnMasterKeyMetadataSignatureVerificationCache.Instance + .GetSignatureVerificationResult(keyStoreName, keyPath, isEnclaveEnabled, CMKSignature); - ColumnMasterKeyMetadataSignatureVerificationCache.AddSignatureVerificationResult(keyStoreName, keyPath, isEnclaveEnabled, CMKSignature, isValidSignature); + if (cachedResult == SignatureVerificationResult.NotFound) + { + // Cache miss: verify with the provider and cache the result. + // Exceptions from VerifyColumnMasterKeyMetadata bubble up to the outer catch. + isValidSignature = provider.VerifyColumnMasterKeyMetadata(keyPath, isEnclaveEnabled, CMKSignature); + ColumnMasterKeyMetadataSignatureVerificationCache.Instance + .AddSignatureVerificationResult(keyStoreName, keyPath, isEnclaveEnabled, CMKSignature, isValidSignature); } else { - isValidSignature = signatureVerificationResult; + isValidSignature = cachedResult == SignatureVerificationResult.True; } } } 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 8f0d7fcba0..d91616fd38 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -511,6 +511,11 @@ internal static Exception UnsupportedAuthenticationByProvider(string authenticat return ADP.NotSupported(StringsHelper.GetString(Strings.SQL_UnsupportedAuthenticationByProvider, type, authentication)); } + internal static Exception UseWamBrokerRequiresAzureExtensionUpgrade() + { + return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_UseWamBrokerRequiresAzureExtensionUpgrade)); + } + internal static Exception CannotFindAuthProvider(SqlAuthenticationMethod authentication) { string authName = authentication.ToString(); @@ -746,6 +751,10 @@ internal static Exception ParsingErrorLength(ParsingErrorState state, int length { return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_ParsingErrorLength, ((int)state).ToString(CultureInfo.InvariantCulture), length)); } + internal static Exception ParsingErrorLength(ParsingErrorState state, uint length) + { + return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_ParsingErrorLength, ((int)state).ToString(CultureInfo.InvariantCulture), length)); + } internal static Exception ParsingErrorStatus(ParsingErrorState state, int status) { return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_ParsingErrorStatus, ((int)state).ToString(CultureInfo.InvariantCulture), status)); 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 cf8f4b3893..fbdf2dbb53 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs @@ -84,6 +84,17 @@ internal static class TdsEnums public const int MAX_PACKET_SIZE = 32768; public const int MAX_SERVER_USER_NAME = 256; // obtained from luxor + // Maximum allowed data length for token payloads (feature ext ack, + // session state, fedauth info). Prevents a malicious server from causing + // unbounded memory allocation via spoofed token length fields. + internal const int MaxTokenDataLength = 1 << 20; // 1 MB + + // Maximum allowed data length for a DTC promote transaction propagation token. + internal const int MaxPromoteTransactionLength = 1 << 16; // 64 KB + + // Maximum valid wire size for datetime types (DateTimeOffset = 5 time + 3 date + 2 offset). + internal const int MaxDateTimeLength = 10; + // Severity 0 - 10 indicates informational (non-error) messages // Severity 11 - 16 indicates errors that can be corrected by user (syntax errors, etc...) // Severity 17 - 19 indicates failure due to insufficient resources in the server 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 088d41d94d..c2b17b22d7 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -208,7 +208,7 @@ static TdsParser() { // For CoreCLR, we need to register the ANSI Code Page encoding provider before attempting to get an Encoding from a CodePage // For a default installation of SqlServer the encoding exchanged during Login is 1252. This encoding is not loaded by default - // See Remarks at https://msdn.microsoft.com/en-us/library/system.text.encodingprovider(v=vs.110).aspx + // See Remarks at https://msdn.microsoft.com/en-us/library/system.text.encodingprovider(v=vs.110).aspx // SqlClient needs to register the encoding providers to make sure that even basic scenarios work with Sql Server. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } @@ -683,7 +683,7 @@ internal void RemoveEncryption() // create a new packet encryption changes the internal packet size Bug# 228403 _physicalStateObj.ClearAllWritePackets(); - } + } internal void EnableMars() { @@ -1376,11 +1376,11 @@ internal void TdsLogin( int feOffset = length; // calculate and reserve the required bytes for the featureEx length = ApplyFeatureExData( - requestedFeatures, - recoverySessionData, + requestedFeatures, + recoverySessionData, fedAuthFeatureExtensionData, UserAgent.Ucs2Bytes, - useFeatureExt, + useFeatureExt, length ); @@ -2792,7 +2792,7 @@ internal TdsOperationStatus TryRun(RunBehavior runBehavior, SqlCommand cmdHandle { _connHandler._federatedAuthenticationInfoReceived = true; SqlFedAuthInfo info; - + result = TryProcessFedAuthInfo(stateObj, tokenLength, out info); if (result != TdsOperationStatus.Done) { @@ -3348,6 +3348,10 @@ private TdsOperationStatus TryProcessEnvChange(int tokenLength, TdsParserStateOb // new value has 4 byte length return result; } + if (env._newLength < 0 || env._newLength > TdsEnums.MaxPromoteTransactionLength) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, env._newLength); + } // read new value with 4 byte length env._newBinValue = new byte[env._newLength]; result = stateObj.TryReadByteArray(env._newBinValue, env._newLength); @@ -3846,10 +3850,15 @@ private TdsOperationStatus TryProcessFeatureExtAck(TdsParserStateObject stateObj { return result; } - byte[] data = new byte[dataLen]; - if (dataLen > 0) + if (dataLen > (uint)TdsEnums.MaxTokenDataLength) { - result = stateObj.TryReadByteArray(data, checked((int)dataLen)); + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, dataLen); + } + int dataLength = (int)dataLen; + byte[] data = new byte[dataLength]; + if (dataLength > 0) + { + result = stateObj.TryReadByteArray(data, dataLength); if (result != TdsOperationStatus.Done) { return result; @@ -4169,6 +4178,10 @@ private TdsOperationStatus TryProcessSessionState(TdsParserStateObject stateObj, { throw SQL.ParsingErrorLength(ParsingErrorState.SessionStateLengthTooShort, length); } + if (length > TdsEnums.MaxTokenDataLength) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, length); + } uint seqNum; TdsOperationStatus result = stateObj.TryReadUInt32(out seqNum); if (result != TdsOperationStatus.Done) @@ -4218,6 +4231,10 @@ private TdsOperationStatus TryProcessSessionState(TdsParserStateObject stateObj, return result; } } + if (stateLen < 0 || stateLen > TdsEnums.MaxTokenDataLength) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, stateLen); + } byte[] buffer = null; lock (sdata._delta) { @@ -4435,6 +4452,10 @@ private TdsOperationStatus TryProcessFedAuthInfo(TdsParserStateObject stateObj, SqlClientEventSource.Log.TryTraceEvent(" FEDAUTHINFO token stream length too short for CountOfInfoIDs."); throw SQL.ParsingErrorLength(ParsingErrorState.FedAuthInfoLengthTooShortForCountOfInfoIds, tokenLen); } + if (tokenLen > TdsEnums.MaxTokenDataLength) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, tokenLen); + } // read how many FedAuthInfo options there are uint optionsCount; @@ -4912,14 +4933,20 @@ internal TdsOperationStatus TryProcessReturnValue(int length, } // always read as sql types - Debug.Assert(valLen < (ulong)(int.MaxValue), "ProcessReturnValue received data size > 2Gb"); - - int intlen = valLen > (ulong)(int.MaxValue) ? int.MaxValue : (int)valLen; + int intlen; if (rec.metaType.IsPlp) { intlen = int.MaxValue; // If plp data, read it all } + else if (valLen > (ulong)int.MaxValue) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, unchecked((int)valLen)); + } + else + { + intlen = (int)valLen; + } if (rec.type == SqlDbTypeExtensions.Vector) { @@ -5790,7 +5817,7 @@ private TdsOperationStatus TryCommonProcessMetaData(TdsParserStateObject stateOb { return result; } - + // read flags and set appropriate flags in structure byte flags; result = stateObj.TryReadByte(out flags); @@ -7119,7 +7146,7 @@ internal TdsOperationStatus TryReadSqlValue(SqlBuffer value, return result; } - // Internally, we use Sqlbinary to deal with varbinary data and store it in + // Internally, we use Sqlbinary to deal with varbinary data and store it in // SqlBuffer as SqlBinary value. #if NET value.SqlBinary = SqlBinary.WrapBytes(b); @@ -7188,9 +7215,20 @@ internal TdsOperationStatus TryReadSqlValue(SqlBuffer value, return TdsOperationStatus.Done; } + // length originates as a single byte on the wire (nullable datetime length prefix), + // but is kept as int to match the TDS parsing API surface where all lengths are int. + // Using byte here would require casts at all call sites and silently truncate values + // from the sql_variant path where lenData is computed arithmetically. private TdsOperationStatus TryReadSqlDateTime(SqlBuffer value, byte tdsType, int length, byte scale, TdsParserStateObject stateObj) { - Span datetimeBuffer = ((uint)length <= 16) ? stackalloc byte[16] : new byte[length]; + // DateTimeOffset is the largest datetime type at 10 bytes (5 time + 3 date + 2 offset). + // Reject anything larger to prevent heap allocation from spoofed metadata. + if (length < 0 || length > TdsEnums.MaxDateTimeLength) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, length); + } + + Span datetimeBuffer = stackalloc byte[TdsEnums.MaxDateTimeLength]; TdsOperationStatus result = stateObj.TryReadByteArray(datetimeBuffer, length); if (result != TdsOperationStatus.Done) @@ -7446,9 +7484,11 @@ internal TdsOperationStatus TryReadSqlValueInternal(SqlBuffer value, byte tdsTyp case TdsEnums.SQLVECTOR: { // Note: Better not come here with plp data!! - Debug.Assert(length <= TdsEnums.MAXSIZE); - byte[] b = new byte[length]; - result = stateObj.TryReadByteArrayWithContinue(length, isPlp: false, out b); + if (length < 0 || length > TdsEnums.MAXSIZE) + { + throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, length); + } + result = stateObj.TryReadByteArrayWithContinue(length, isPlp: false, out byte[] b); if (result != TdsOperationStatus.Done) { return result; @@ -9278,7 +9318,7 @@ internal int WriteFedAuthFeatureRequest(FederatedAuthenticationFeatureExtensionD /// internal int WriteVectorSupportFeatureRequest(bool write) { - const int len = 6; + const int len = 6; if (write) { @@ -10476,7 +10516,7 @@ private Task TDSExecuteRPCAddParameter(TdsParserStateObject stateObj, SqlParamet { isSqlVal = param.ParameterIsSqlType; // We have to forward the TYPE info, we need to know what type we are returning. Once we null the parameter we will no longer be able to distinguish what type were seeing. - // Output parameter of SqlDbType vector are defined through SqlParameter.Value. + // Output parameter of SqlDbType vector are defined through SqlParameter.Value. // This check ensures that we do not discard the parameter value when SqlDbType is vector. if (mt.SqlDbType != SqlDbTypeExtensions.Vector) { @@ -10761,7 +10801,7 @@ private Task TDSExecuteRPCAddParameter(TdsParserStateObject stateObj, SqlParamet Debug.Assert(udtVal != null, "GetBytes returned null instance. Make sure that it always returns non-null value"); size = udtVal.Length; - + if (size >= maxSupportedSize && maxsize != -1) { throw SQL.UDTInvalidSize(maxsize, maxSupportedSize); @@ -13263,7 +13303,7 @@ private Task WriteUnterminatedValue(object value, MetaType type, byte scale, int { if (type.NullableType == TdsEnums.SQLJSON) { - // TODO : Performance and BOM check. Saurabh + // TODO : Performance and BOM check. Saurabh byte[] jsonAsBytes = Encoding.UTF8.GetBytes((string)value); WriteInt(jsonAsBytes.Length, stateObj); return stateObj.WriteByteArray(jsonAsBytes, jsonAsBytes.Length, 0, canAccumulate: false); @@ -13921,13 +13961,13 @@ internal TdsOperationStatus TryReadPlpUnicodeCharsWithContinue(TdsParserStateObj } TdsOperationStatus result = TryReadPlpUnicodeChars( - ref temp, - 0, - length >> 1, - stateObj, - out length, + ref temp, + 0, + length >> 1, + stateObj, + out length, supportRentedBuff: !canContinue, // do not use the arraypool if we are going to keep the buffer in the snapshot - rentedBuff: ref buffIsRented, + rentedBuff: ref buffIsRented, startOffset, canContinue ); @@ -14137,7 +14177,7 @@ bool writeDataSizeToSnapshot stateObj._longlenleft--; if (writeDataSizeToSnapshot) { - // we need to write the single b1 byte to the array because we may run out of data + // we need to write the single b1 byte to the array because we may run out of data // and need to wait for another packet buff[offst] = (char)((b1 & 0xff)); currentPacketId = IncrementSnapshotDataSize(stateObj, restartingDataSizeCount, currentPacketId, 1); diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index 2cae12cb6c..2226209a13 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -9325,7 +9325,16 @@ internal static string SQL_CannotFindActiveDirectoryAuthProvider { return ResourceManager.GetString("SQL_CannotFindActiveDirectoryAuthProvider", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to a version that includes this type.. + /// + internal static string SQL_UseWamBrokerRequiresAzureExtensionUpgrade { + get { + return ResourceManager.GetString("SQL_UseWamBrokerRequiresAzureExtensionUpgrade", resourceCulture); + } + } + /// /// Looks up a localized string similar to Failed to read the config section for authentication providers.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index 0006d91c9d..179c9590a8 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -2589,6 +2589,9 @@ Failed to read the config section for authentication providers. + + Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to at least v1.1.0 that includes this type. + Parameter '{0}' cannot be null or empty. diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlCommandTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlCommandTest.cs index 80ceba25af..59f35b4941 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlCommandTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlCommandTest.cs @@ -562,6 +562,20 @@ public void ParameterCollectionTest() } } + /// + /// Verifies that SqlCommand.Cancel() is a no-op when Connection is null, + /// rather than throwing a NullReferenceException. Regression test for #4327. + /// + [Fact] + public void Cancel_WithNullConnection_DoesNotThrow() + { + using SqlCommand cmd = new SqlCommand(); + Assert.Null(cmd.Connection); + + // Should be a no-op, not throw NullReferenceException + cmd.Cancel(); + } + private static SqlConnection GetNonConnectingConnection() => new SqlConnection("Initial Catalog=a;Server=b;User ID=c;Password=d"); } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs index 2e652841c9..fc310bf3d3 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs @@ -311,6 +311,8 @@ private static Task AcquireTokenAsync(string authorityURL, string userID public static bool IsKerberosTest => !string.IsNullOrEmpty(KerberosDomainUser) && !string.IsNullOrEmpty(KerberosDomainPassword); + public static bool IsNotKerberosTest => !IsKerberosTest; + #nullable enable /// diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AsyncTest/AsyncCancelledConnectionsTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AsyncTest/AsyncCancelledConnectionsTest.cs index 9dd87d8f21..00bba56fc2 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AsyncTest/AsyncCancelledConnectionsTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AsyncTest/AsyncCancelledConnectionsTest.cs @@ -25,7 +25,10 @@ public class AsyncCancelledConnectionsTest private Random _random; // Disabled on Azure since this test fails on concurrent runs on same database. - [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureServer))] + // Disabled on Kerberos and Managed Instance pipelines due to environment-specific instability. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsNotAzureServer), nameof(DataTestUtility.IsNotManagedInstance), + nameof(DataTestUtility.IsNotKerberosTest))] [InlineData(true)] [InlineData(false)] public async Task CancelAsyncConnections(bool useMars) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderTest.cs index e0a2c85e8f..7a11401531 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/DataReaderTest/DataReaderTest.cs @@ -702,6 +702,22 @@ DROP TABLE IF EXISTS [{tableName}] } } + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public static async Task GetCharsSequentialAccess_NullBufferNegativeBufferIndex_ThrowsArgumentOutOfRange() + { + using var connection = new SqlConnection(DataTestUtility.TCPConnectionString); + await connection.OpenAsync(); + + using var command = connection.CreateCommand(); + command.CommandText = "SELECT CONVERT(NVARCHAR(MAX), 'test')"; + + using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess); + Assert.True(await reader.ReadAsync()); + + var ex = Assert.Throws(() => reader.GetChars(0, 0, null, -1, 0)); + Assert.Equal("bufferIndex", ex.ParamName); + } + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] public static async Task CanGetCharsSequentially() { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs index a5db02faa0..2435eb2f4a 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs @@ -2,7 +2,7 @@ // 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.Tests.Common; using Xunit; namespace Microsoft.Data.SqlClient.UnitTests; @@ -18,6 +18,36 @@ public class LocalAppContextSwitchesTest [Fact] public void TestDefaultAppContextSwitchValues() { + // LocalAppContextSwitches caches each switch value on first access for + // the lifetime of the process. Other tests running in parallel may + // already have triggered caching, or may use LocalAppContextSwitchesHelper + // to mutate the cached fields via reflection. To make this test + // deterministic, acquire the helper (which serializes against every + // other helper user via a process-wide semaphore) and reset each + // cached field to None so the properties re-read from AppContext. + using LocalAppContextSwitchesHelper switchesHelper = new(); + + switchesHelper.EnableMultiSubnetFailoverByDefault = null; + switchesHelper.IgnoreServerProvidedFailoverPartner = null; + switchesHelper.LegacyRowVersionNullBehavior = null; + switchesHelper.LegacyVarTimeZeroScaleBehaviour = null; + switchesHelper.MakeReadAsyncBlocking = null; + switchesHelper.SuppressInsecureTlsWarning = null; + switchesHelper.TruncateScaledDecimal = null; + switchesHelper.UseCompatibilityAsyncBehaviour = null; + switchesHelper.UseCompatibilityProcessSni = null; + switchesHelper.UseConnectionPoolV2 = null; + switchesHelper.UseMinimumLoginTimeout = null; + #if NET + switchesHelper.GlobalizationInvariantMode = null; + #endif + #if NET && _WINDOWS + switchesHelper.UseManagedNetworking = null; + #endif + #if NETFRAMEWORK + switchesHelper.DisableTnirByDefault = null; + #endif + Assert.False(LocalAppContextSwitches.LegacyRowVersionNullBehavior); Assert.False(LocalAppContextSwitches.SuppressInsecureTlsWarning); Assert.False(LocalAppContextSwitches.MakeReadAsyncBlocking); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SignatureVerificationCacheTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SignatureVerificationCacheTests.cs new file mode 100644 index 0000000000..a03dd8910e --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SignatureVerificationCacheTests.cs @@ -0,0 +1,49 @@ +// 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 Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests +{ + public class SignatureVerificationCacheTests + { + [Fact] + public void GetSignatureVerificationResult_ReturnsFalseForCachedFailure() + { + ColumnMasterKeyMetadataSignatureVerificationCache cache = ColumnMasterKeyMetadataSignatureVerificationCache.Instance; + string keyStoreName = $"TEST_PROVIDER_{Guid.NewGuid():N}"; + string masterKeyPath = $"https://unit-test/{Guid.NewGuid():N}"; + byte[] signature = [1, 2, 3, 4]; + + cache.AddSignatureVerificationResult(keyStoreName, masterKeyPath, allowEnclaveComputations: true, signature, result: false); + + Assert.Equal(SignatureVerificationResult.False, cache.GetSignatureVerificationResult(keyStoreName, masterKeyPath, allowEnclaveComputations: true, signature)); + } + + [Fact] + public void GetSignatureVerificationResult_ReturnsTrueForCachedSuccess() + { + ColumnMasterKeyMetadataSignatureVerificationCache cache = ColumnMasterKeyMetadataSignatureVerificationCache.Instance; + string keyStoreName = $"TEST_PROVIDER_{Guid.NewGuid():N}"; + string masterKeyPath = $"https://unit-test/{Guid.NewGuid():N}"; + byte[] signature = [4, 3, 2, 1]; + + cache.AddSignatureVerificationResult(keyStoreName, masterKeyPath, allowEnclaveComputations: true, signature, result: true); + + Assert.Equal(SignatureVerificationResult.True, cache.GetSignatureVerificationResult(keyStoreName, masterKeyPath, allowEnclaveComputations: true, signature)); + } + + [Fact] + public void GetSignatureVerificationResult_ReturnsNotFoundForCacheMiss() + { + ColumnMasterKeyMetadataSignatureVerificationCache cache = ColumnMasterKeyMetadataSignatureVerificationCache.Instance; + string keyStoreName = $"TEST_PROVIDER_{Guid.NewGuid():N}"; + string masterKeyPath = $"https://unit-test/{Guid.NewGuid():N}"; + byte[] signature = [9, 9, 9, 9]; + + Assert.Equal(SignatureVerificationResult.NotFound, cache.GetSignatureVerificationResult(keyStoreName, masterKeyPath, allowEnclaveComputations: true, signature)); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs index 778d69991b..08089abe98 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs @@ -73,4 +73,200 @@ public void Abstractions_And_Manager_GetSetProvider_Equivalent() SqlAuthenticationProvider.GetProvider( SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); } + + // Regression: the manager's static initializer reflectively constructs the Azure extension's + // ActiveDirectoryAuthenticationProvider. That class has overlapping 1-arg constructors + // ((string) and (ProviderOptions)), so calling Activator.CreateInstance(type, [null]) used + // to throw AmbiguousMatchException -- which surfaced as TypeInitializationException from + // GetProvider and broke every AD-authenticated connection. Calling GetProvider for an AD + // method must succeed (returning either the registered provider or null) and must not throw. + [Fact] + public void GetProvider_ForActiveDirectoryMethod_DoesNotThrow() + { + foreach (SqlAuthenticationMethod method in new[] + { + SqlAuthenticationMethod.ActiveDirectoryIntegrated, + #pragma warning disable CS0618 // ActiveDirectoryPassword is obsolete. + SqlAuthenticationMethod.ActiveDirectoryPassword, + #pragma warning restore CS0618 + SqlAuthenticationMethod.ActiveDirectoryInteractive, + SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, + SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, + SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, + SqlAuthenticationMethod.ActiveDirectoryMSI, + SqlAuthenticationMethod.ActiveDirectoryDefault, + SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, + }) + { + // No assertion on the value -- the provider may or may not be installed depending on + // whether the Azure extension is on disk. We only assert no throw (which is what a + // TypeInitializationException from the static initializer would do). + _ = SqlAuthenticationProviderManager.GetProvider(method); + } + } + + // CreateAzureAuthenticationProvider tests ---------------------------------------------- + // + // Each Stub* container mimics one shape the real Azure extension might expose: + // * StubModern - both a (string) ctor and an (Options) ctor. + // * StubLegacy - only the (string) ctor; no Options type at all. + // * StubMinimal - only a parameterless ctor. + // + // The helper takes a Type directly, so these stubs do not need any particular full name. + + public class StubProviderBase : SqlAuthenticationProvider + { + public string? CapturedApplicationClientId; + public bool? CapturedUseWamBroker; + public bool ParameterlessCtorUsed; + public bool StringCtorUsed; + public bool OptionsCtorUsed; + + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) + => Task.FromResult(new SqlAuthenticationToken("stub", DateTimeOffset.UtcNow.AddMinutes(5))); + + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; + } + + public static class StubModern + { + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + public string? ApplicationClientId { get; set; } + public bool UseWamBroker { get; set; } + } + + public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase + { + public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } + + public ActiveDirectoryAuthenticationProvider(string applicationClientId) + { + StringCtorUsed = true; + CapturedApplicationClientId = applicationClientId; + } + + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) + { + OptionsCtorUsed = true; + CapturedApplicationClientId = options.ApplicationClientId; + CapturedUseWamBroker = options.UseWamBroker; + } + } + } + + public static class StubLegacy + { + // No Options type defined -- mimics older Azure extension versions. + public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase + { + public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } + + public ActiveDirectoryAuthenticationProvider(string applicationClientId) + { + StringCtorUsed = true; + CapturedApplicationClientId = applicationClientId; + } + } + } + + public static class StubMinimal + { + // Parameterless only -- mimics a hypothetical extension with no 1-arg ctors at all. + public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase + { + public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } + } + } + + [Fact] + public void CreateAzureAuthenticationProvider_NeitherConfigured_UsesParameterlessCtor() + { + var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( + typeof(StubModern.ActiveDirectoryAuthenticationProvider), + typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), + applicationClientId: null, + useWamBroker: null); + + var stub = Assert.IsType(instance); + Assert.True(stub.ParameterlessCtorUsed); + Assert.False(stub.StringCtorUsed); + Assert.False(stub.OptionsCtorUsed); + Assert.Null(stub.CapturedApplicationClientId); + Assert.Null(stub.CapturedUseWamBroker); + } + + [Fact] + public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsAvailable_UsesOptionsCtor() + { + var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( + typeof(StubModern.ActiveDirectoryAuthenticationProvider), + typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), + applicationClientId: "app-123", + useWamBroker: null); + + var stub = Assert.IsType(instance); + Assert.True(stub.OptionsCtorUsed); + Assert.False(stub.StringCtorUsed); + Assert.Equal("app-123", stub.CapturedApplicationClientId); + Assert.Equal(false, stub.CapturedUseWamBroker); + } + + [Fact] + public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsMissing_FallsBackToStringCtor() + { + var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( + typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), + optionsType: null, + applicationClientId: "legacy-456", + useWamBroker: null); + + var stub = Assert.IsType(instance); + Assert.True(stub.StringCtorUsed); + Assert.False(stub.OptionsCtorUsed); + Assert.False(stub.ParameterlessCtorUsed); + Assert.Equal("legacy-456", stub.CapturedApplicationClientId); + Assert.Null(stub.CapturedUseWamBroker); + } + + [Fact] + public void CreateAzureAuthenticationProvider_AppIdOnly_NoCompatibleCtor_ReturnsNull() + { + var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( + typeof(StubMinimal.ActiveDirectoryAuthenticationProvider), + optionsType: null, + applicationClientId: "no-ctor", + useWamBroker: null); + + Assert.Null(instance); + } + + [Fact] + public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsMissing_Throws() + { + InvalidOperationException ex = Assert.Throws(() => + SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( + typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), + optionsType: null, + applicationClientId: null, + useWamBroker: true)); + + Assert.Contains("ActiveDirectoryAuthenticationProviderOptions", ex.Message); + Assert.Contains("Microsoft.Data.SqlClient.Extensions.Azure", ex.Message); + } + + [Fact] + public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_UsesOptionsCtor() + { + var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( + typeof(StubModern.ActiveDirectoryAuthenticationProvider), + typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), + applicationClientId: "app-789", + useWamBroker: true); + + var stub = Assert.IsType(instance); + Assert.True(stub.OptionsCtorUsed); + Assert.Equal("app-789", stub.CapturedApplicationClientId); + Assert.Equal(true, stub.CapturedUseWamBroker); + } } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionEnhancedRoutingTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionEnhancedRoutingTests.cs index 7d8941a07d..c85365d98e 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionEnhancedRoutingTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionEnhancedRoutingTests.cs @@ -14,6 +14,8 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests; /// /// Tests connection routing using the enhanced routing feature extension and envchange token /// +// TODO: Do we need this collection? It serializes all tests within it, which we probably don't +// need since each test uses its own TDS Server with ephemeral listen port. [Collection("SimulatedServerTests")] public class ConnectionEnhancedRoutingTests { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs index 4d95c22cb3..8a41477f26 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionFailoverTests.cs @@ -12,6 +12,8 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { [Trait("Category", "flaky")] + // TODO: Do we need this collection? It serializes all tests within it, which we probably don't + // need since each test uses its own TDS Server with ephemeral listen port. [Collection("SimulatedServerTests")] public class ConnectionFailoverTests { @@ -173,7 +175,7 @@ public void NetworkTimeout_ShouldFail() InitialCatalog = "master",// Required for failover partner to work ConnectTimeout = 1, ConnectRetryInterval = 1, - ConnectRetryCount = 0, // Disable retry + ConnectRetryCount = 0, // Disable retry Encrypt = false, MultiSubnetFailover = false, #if NETFRAMEWORK @@ -380,6 +382,7 @@ public void TransientFault_ShouldConnectToPrimary(uint errorCode) [InlineData(40613)] [InlineData(42108)] [InlineData(42109)] + [Trait("Category", "flaky")] public void TransientFault_RetryDisabled_ShouldFail(uint errorCode) { // Arrange @@ -459,7 +462,7 @@ public void TransientFault_WithUserProvidedPartner_ShouldConnectToPrimary(uint e FailoverPartner = $"localhost:{failoverServer.EndPoint.Port}", // User provided failover partner }; using SqlConnection connection = new(builder.ConnectionString); - + // Act connection.Open(); @@ -475,6 +478,7 @@ public void TransientFault_WithUserProvidedPartner_ShouldConnectToPrimary(uint e [InlineData(40613)] [InlineData(42108)] [InlineData(42109)] + [Trait("Category", "flaky")] public void TransientFault_WithUserProvidedPartner_RetryDisabled_ShouldFail(uint errorCode) { // Arrange diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionReadOnlyRoutingTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionReadOnlyRoutingTests.cs index f0618ac269..8220871f22 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionReadOnlyRoutingTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionReadOnlyRoutingTests.cs @@ -11,6 +11,8 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { + // TODO: Do we need this collection? It serializes all tests within it, which we probably don't + // need since each test uses its own TDS Server with ephemeral listen port. [Collection("SimulatedServerTests")] public class ConnectionReadOnlyRoutingTests { @@ -71,7 +73,7 @@ public void RecursivelyRoutedConnection(int layers) router.Start(); routingLayers.Push(router); lastEndpoint = router.EndPoint; - lastConnectionString = (new SqlConnectionStringBuilder() { + lastConnectionString = (new SqlConnectionStringBuilder() { DataSource = $"localhost,{lastEndpoint.Port}", ApplicationIntent = ApplicationIntent.ReadOnly, Encrypt = false @@ -114,8 +116,8 @@ public async Task RecursivelyRoutedAsyncConnection(int layers) router.Start(); routingLayers.Push(router); lastEndpoint = router.EndPoint; - lastConnectionString = (new SqlConnectionStringBuilder() { - DataSource = $"localhost,{lastEndpoint.Port}", + lastConnectionString = (new SqlConnectionStringBuilder() { + DataSource = $"localhost,{lastEndpoint.Port}", ApplicationIntent = ApplicationIntent.ReadOnly, Encrypt = false }).ConnectionString; diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTests.cs index 6d89246776..4f45e49782 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTests.cs @@ -11,6 +11,8 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { [Trait("Category", "flaky")] + // TODO: Do we need this collection? It serializes all tests within it, which we probably don't + // need since each test uses its own TDS Server with ephemeral listen port. [Collection("SimulatedServerTests")] public class ConnectionRoutingTests { @@ -195,7 +197,7 @@ public void NetworkTimeoutAtRoutedLocation_RetryDisabled_ShouldFail() // Act var e = Assert.Throws(connection.Open); - // Assert + // Assert Assert.Equal(ConnectionState.Closed, connection.State); Assert.Contains("Connection Timeout Expired", e.Message); } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTestsAzure.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTestsAzure.cs index dd945e37f3..74190e847b 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTestsAzure.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/ConnectionRoutingTestsAzure.cs @@ -11,6 +11,8 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests { [Trait("Category", "flaky")] + // TODO: Do we need this collection? It serializes all tests within it, which we probably don't + // need since each test uses its own TDS Server with ephemeral listen port. [Collection("SimulatedServerTests")] public class ConnectionRoutingTestsAzure : IDisposable { @@ -22,8 +24,8 @@ public ConnectionRoutingTestsAzure() adpHelper.AddAzureSqlServerEndpoint("localhost"); } - public void Dispose() - { + public void Dispose() + { adpHelper.Dispose(); } @@ -159,6 +161,7 @@ public void NetworkDelayAtRoutedLocation_RetryDisabled_ShouldSucceed() } [Fact] + [Trait("Category", "flaky")] public void NetworkTimeoutAtRoutedLocation_RetryDisabled_ShouldFail() { // Arrange @@ -192,7 +195,7 @@ public void NetworkTimeoutAtRoutedLocation_RetryDisabled_ShouldFail() // Act var e = Assert.Throws(connection.Open); - // Assert + // Assert Assert.Equal(ConnectionState.Closed, connection.State); Assert.Contains("Connection Timeout Expired", e.Message); } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtAckBoundsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtAckBoundsTests.cs new file mode 100644 index 0000000000..20c94c0361 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtAckBoundsTests.cs @@ -0,0 +1,163 @@ +// 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.IO; +using System.Linq; +using Microsoft.SqlServer.TDS; +using Microsoft.SqlServer.TDS.FeatureExtAck; +using Microsoft.SqlServer.TDS.Servers; +using TDSDoneToken = global::Microsoft.SqlServer.TDS.Done.TDSDoneToken; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests; + +/// +/// Tests that the TDS parser rejects feature extension acknowledgment tokens +/// with data lengths exceeding protocol-reasonable bounds. This prevents a +/// malicious server from causing unbounded memory allocation on the client. +/// +// TODO: Do we need this collection? It serializes all tests within it, which we probably don't +// need since each test uses its own TDS Server with ephemeral listen port. +[Collection("SimulatedServerTests")] +public class FeatureExtAckBoundsTests : IDisposable +{ + private readonly TdsServerFixture _fixture; + private readonly TdsServer _server; + private readonly string _connectionString; + + public FeatureExtAckBoundsTests() + { + _fixture = new TdsServerFixture(); + _server = _fixture.TdsServer; + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{_server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false + }; + _connectionString = builder.ConnectionString; + } + + public void Dispose() => _fixture.Dispose(); + + /// + /// Verifies that the TDS parser rejects a FeatureExtAck token whose data length + /// field exceeds (1 MB), throwing a + /// parsing error instead of attempting an unbounded heap allocation. + /// This guards against CVE denial-of-service via pre-auth memory exhaustion. + /// + [Fact] + public void FeatureExtAck_OversizedDataLength_ThrowsParsingError() + { + // Arrange: inject a malicious FeatureExtAck token with an absurdly large data length + _server.OnAuthenticationResponseCompleted = responseMessage => + { + // Remove any existing FeatureExtAck token + var existing = responseMessage.OfType().FirstOrDefault(); + if (existing != null) + { + responseMessage.Remove(existing); + } + + // Insert a malicious token with oversized data length before the DONE token + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + responseMessage.Insert(doneIndex, new MaliciousFeatureExtAckToken( + featureId: (TDSFeatureID)TdsEnums.FEATUREEXT_GLOBALTRANSACTIONS, + claimedDataLen: (uint)(TdsEnums.MaxTokenDataLength + 1))); + }; + + // Act & Assert: connection should fail with a parsing error, NOT an OutOfMemoryException + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + + // The exception message should indicate a corrupted TDS stream + // with the oversized length value, not an OOM from attempting the allocation. + Assert.Contains("Error state: 18", ex.Message); // ParsingErrorState.CorruptedTdsStream = 18 + Assert.Contains($"Length: {TdsEnums.MaxTokenDataLength + 1}", ex.Message); + } + + /// + /// Verifies that a FeatureExtAck token with a data length at exactly the + /// allowed maximum is accepted without error, confirming there is no + /// off-by-one in the bounds check. + /// + [Fact] + public void FeatureExtAck_MaxAllowedDataLength_DoesNotThrow() + { + // Arrange: inject a FeatureExtAck token whose declared data length equals + // MaxTokenDataLength exactly. The bounds check should NOT fire for this + // value. The connection will fail for other reasons (not enough data on + // the wire), but the error must NOT be state 18 (CorruptedTdsStream). + _server.OnAuthenticationResponseCompleted = responseMessage => + { + var existing = responseMessage.OfType().FirstOrDefault(); + if (existing != null) + { + responseMessage.Remove(existing); + } + + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Insert token with dataLen = MaxTokenDataLength (at boundary, not over) + responseMessage.Insert(doneIndex, new MaliciousFeatureExtAckToken( + featureId: (TDSFeatureID)TdsEnums.FEATUREEXT_GLOBALTRANSACTIONS, + claimedDataLen: (uint)TdsEnums.MaxTokenDataLength)); + }; + + using SqlConnection connection = new(_connectionString); + // The connection will fail (insufficient data for the claimed length), + // but the failure must NOT be the bounds-check error. + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.DoesNotContain("Error state: 18", ex.Message); + } + + /// + /// A custom TDS packet token that writes a FEATUREEXTACK token with a fraudulently + /// large data length field. This simulates a malicious server attempting to cause + /// the client to allocate an unbounded byte array. + /// + private sealed class MaliciousFeatureExtAckToken : TDSPacketToken + { + private readonly TDSFeatureID _featureId; + private readonly uint _claimedDataLen; + + public MaliciousFeatureExtAckToken(TDSFeatureID featureId, uint claimedDataLen) + { + _featureId = featureId; + _claimedDataLen = claimedDataLen; + } + + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // Write the FEATUREEXTACK token type (0xAE) + destination.WriteByte((byte)TDSTokenType.FeatureExtAck); + + // Write the feature ID byte + destination.WriteByte((byte)_featureId); + + // Write the claimed data length (uint32, little-endian) — this is the lie + byte[] lenBytes = BitConverter.GetBytes(_claimedDataLen); + destination.Write(lenBytes, 0, 4); + + // Write only 1 byte of actual data (the client will try to read _claimedDataLen bytes + // but we only provide 1 — the bounds check should fire before the read attempt) + destination.WriteByte(0x01); + + // Write terminator + destination.WriteByte((byte)TDSFeatureID.Terminator); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtensionNegotiationTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtensionNegotiationTests.cs index e6342c4fa8..0141d44000 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtensionNegotiationTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtensionNegotiationTests.cs @@ -13,6 +13,8 @@ namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests; +// Serializes execution with other SimulatedServerTests classes to avoid port/resource conflicts. +// Required here because IClassFixture shares a single TdsServer instance across all tests in this class. [Collection("SimulatedServerTests")] public class FeatureExtensionNegotiationTests : IClassFixture { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/TdsTokenBoundsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/TdsTokenBoundsTests.cs new file mode 100644 index 0000000000..ef975dccb3 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/TdsTokenBoundsTests.cs @@ -0,0 +1,1051 @@ +// 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.IO; +using System.Linq; +using Microsoft.SqlServer.TDS; +using Microsoft.SqlServer.TDS.ColMetadata; +using Microsoft.SqlServer.TDS.Done; +using Microsoft.SqlServer.TDS.FeatureExtAck; +using Microsoft.SqlServer.TDS.Servers; +using Microsoft.SqlServer.TDS.SessionState; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests; + +/// +/// Tests that the TDS parser rejects various token types with data lengths +/// exceeding protocol-reasonable bounds, preventing unbounded memory allocation +/// from a malicious server. +/// +// Serializes execution with other SimulatedServerTests classes. Required here because +// DebugAssertSuppressor mutates the global Trace.Listeners collection, which is not +// safe to do concurrently with other tests that may trigger Debug.Assert. +[Collection("SimulatedServerTests")] +public class TdsTokenBoundsTests : IDisposable +{ + private readonly TdsServerFixture _fixture; + private readonly TdsServer _server; + private readonly string _connectionString; + + public TdsTokenBoundsTests() + { + _fixture = new TdsServerFixture(); + _server = _fixture.TdsServer; + SqlConnectionStringBuilder builder = new() + { + DataSource = $"localhost,{_server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false + }; + _connectionString = builder.ConnectionString; + } + + public void Dispose() => _fixture.Dispose(); + + // ────────────────────────────────────────────────────────────────────────── + // Test 1: SessionState token with oversized total length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessSessionState rejects a SessionState token (0xE4) + /// whose total length field exceeds (1 MB), + /// preventing unbounded memory allocation from a spoofed token length. + /// + [Fact] + public void SessionState_OversizedTotalLength_ThrowsParsingError() + { + _server.OnAuthenticationResponseCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Inject a SessionState token claiming a total length exceeding MaxTokenDataLength + responseMessage.Insert(doneIndex, new MaliciousSessionStateToken( + claimedTotalLength: (uint)(TdsEnums.MaxTokenDataLength + 1))); + }; + + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains($"Length: {TdsEnums.MaxTokenDataLength + 1}", ex.Message); + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 2: SessionState token with oversized inner option length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessSessionState rejects an individual session state + /// option whose inner data length (encoded via the 0xFF + DWORD path) exceeds + /// , even when the outer token length is valid. + /// + [Fact] + public void SessionState_OversizedInnerOptionLength_ThrowsParsingError() + { + _server.OnAuthenticationResponseCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Inject a SessionState token with a valid outer length but an inner + // state option claiming a huge data length + responseMessage.Insert(doneIndex, new MaliciousSessionStateInnerLenToken( + innerClaimedLength: TdsEnums.MaxTokenDataLength + 1)); + }; + + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains($"Length: {TdsEnums.MaxTokenDataLength + 1}", ex.Message); + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 2b: SessionState token with negative inner option length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessSessionState rejects an individual session state + /// option whose inner data length (encoded via the 0xFF + DWORD path) is negative, + /// which would be interpreted as a huge unsigned value if not bounds-checked. + /// + [Fact] + public void SessionState_NegativeInnerOptionLength_ThrowsParsingError() + { + _server.OnAuthenticationResponseCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Inject a SessionState token with an inner state option claiming + // a negative data length (-1 = 0xFFFFFFFF as uint32) + responseMessage.Insert(doneIndex, new MaliciousSessionStateInnerLenToken( + innerClaimedLength: -1)); + }; + + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains("Length: -1", ex.Message); + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 3: SRECOVERY feature ack with malformed inner state data + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that the secondary parse of FEATUREEXT_SRECOVERY data in + /// SqlConnectionInternal.OnFeatureExtAck rejects inner state options + /// whose claimed length exceeds the remaining buffer, preventing an + /// out-of-bounds read or over-allocation. + /// + [Fact] + public void SRecovery_MalformedInnerStateLength_ThrowsParsingError() + { + _server.OnAuthenticationResponseCompleted = responseMessage => + { + // Remove existing FeatureExtAck if present + var existing = responseMessage.OfType().FirstOrDefault(); + if (existing != null) + { + responseMessage.Remove(existing); + } + + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Inject a FeatureExtAck with SessionRecovery feature containing + // inner state data where a state option claims a length exceeding the buffer + responseMessage.Insert(doneIndex, new MaliciousSRecoveryFeatureExtAckToken()); + }; + + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains("Length: 999", ex.Message); + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 4: FedAuthInfo token with oversized total length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessFedAuthInfo rejects a FedAuthInfo token (0xEE) + /// whose total length exceeds (1 MB). + /// The token type is dispatched unconditionally by TryRun, so this check + /// fires regardless of whether federated authentication was negotiated. + /// + [Fact] + public void FedAuthInfo_OversizedTokenLength_ThrowsParsingError() + { + _server.OnAuthenticationResponseCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Inject a FedAuthInfo token with a total length exceeding MaxTokenDataLength. + // The parser dispatches on token type regardless of whether FedAuth was negotiated. + responseMessage.Insert(doneIndex, new MaliciousFedAuthInfoToken( + claimedLength: TdsEnums.MaxTokenDataLength + 1)); + }; + + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains($"Length: {TdsEnums.MaxTokenDataLength + 1}", ex.Message); + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 5: ENV_PROMOTETRANSACTION with oversized newLength + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessEnvChange rejects a PromoteTransaction + /// environment change token (type 15) whose inner newLength field exceeds + /// (64 KB). A malicious server + /// can set the outer uint16 token length to a small value while writing an + /// int32 inner length claiming gigabytes, causing unbounded allocation. + /// + [Fact] + public void EnvChange_PromoteTransaction_OversizedLength_ThrowsParsingError() + { + _server.OnAuthenticationResponseCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + // Inject a PromoteTransaction env change with a fraudulently large newLength + responseMessage.Insert(doneIndex, new MaliciousPromoteTransactionEnvChangeToken( + claimedNewLength: TdsEnums.MaxPromoteTransactionLength + 1)); + }; + + using SqlConnection connection = new(_connectionString); + Exception ex = Assert.ThrowsAny(connection.Open); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains($"Length: {TdsEnums.MaxPromoteTransactionLength + 1}", ex.Message); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Malicious token helpers + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// Writes a SessionState token (0xE4) with a fraudulently large total length. + /// Wire format: [0xE4][uint32 totalLen][uint32 seqNum][byte status][...] + /// The bounds check fires on the totalLen value before any data is read. + /// + private sealed class MaliciousSessionStateToken : TDSPacketToken + { + private readonly uint _claimedTotalLength; + + public MaliciousSessionStateToken(uint claimedTotalLength) + { + _claimedTotalLength = claimedTotalLength; + } + + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // Token type + destination.WriteByte((byte)TDSTokenType.SessionState); // 0xE4 + + // Total token length (uint32) — this is the fraudulent value + byte[] lenBytes = BitConverter.GetBytes(_claimedTotalLength); + destination.Write(lenBytes, 0, 4); + + // Write minimal valid-looking data: seqNum (4 bytes) + status (1 byte) + // The bounds check should fire before trying to process option data. + destination.Write(new byte[] { 0x01, 0x00, 0x00, 0x00 }, 0, 4); // seqNum = 1 + destination.WriteByte(0x01); // status = recoverable + } + } + + /// + /// Writes a SessionState token (0xE4) with a valid outer length but an inner + /// state option that claims a huge data length (using the 0xFF + DWORD encoding). + /// Wire format: [0xE4][uint32 totalLen][uint32 seqNum][byte status] + /// [byte stateId][0xFF][int32 innerLen][...data...] + /// The inner bounds check fires on the innerLen value. + /// + private sealed class MaliciousSessionStateInnerLenToken : TDSPacketToken + { + private readonly int _innerClaimedLength; + + public MaliciousSessionStateInnerLenToken(int innerClaimedLength) + { + _innerClaimedLength = innerClaimedLength; + } + + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // Token type + destination.WriteByte((byte)TDSTokenType.SessionState); // 0xE4 + + // Calculate total token length: + // seqNum(4) + status(1) + stateId(1) + lenMarker(1) + innerLen(4) + minimal data(1) + uint totalLength = 4 + 1 + 1 + 1 + 4 + 1; + + byte[] lenBytes = BitConverter.GetBytes(totalLength); + destination.Write(lenBytes, 0, 4); + + // Sequence number + destination.Write(new byte[] { 0x01, 0x00, 0x00, 0x00 }, 0, 4); + + // Status (recoverable) + destination.WriteByte(0x01); + + // State option: stateId + destination.WriteByte(0x00); // UserOptions state ID + + // Length marker: 0xFF means next 4 bytes are the DWORD length + destination.WriteByte(0xFF); + + // Inner claimed length — fraudulently large + byte[] innerLenBytes = BitConverter.GetBytes(_innerClaimedLength); + destination.Write(innerLenBytes, 0, 4); + + // Write only 1 byte of actual data + destination.WriteByte(0x42); + } + } + + /// + /// Writes a FeatureExtAck token (0xAE) with a SessionRecovery feature (ID=1) + /// that carries inner state data where a state option claims a length exceeding + /// the remaining buffer. This exercises the bounds check in + /// SqlConnectionInternal.OnFeatureExtAck for FEATUREEXT_SRECOVERY. + /// + private sealed class MaliciousSRecoveryFeatureExtAckToken : TDSPacketToken + { + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // Token type: FEATUREEXTACK + destination.WriteByte((byte)TDSTokenType.FeatureExtAck); // 0xAE + + // Feature ID: SessionRecovery = 0x01 + destination.WriteByte(0x01); + + // The feature data for SRECOVERY is parsed by SqlConnectionInternal.OnFeatureExtAck: + // It reads pairs of [stateId(1)][lenByte(1)][data(len)] or [stateId(1)][0xFF][int32 len][data(len)] + // We'll craft inner data where one option claims length > remaining buffer. + + // Inner data layout: + // stateId(1) + 0xFF marker(1) + int32 len(4) + 1 byte actual data + byte[] innerData; + using (var ms = new MemoryStream()) + { + ms.WriteByte(0x00); // stateId = 0 + + // Use 0xFF marker to indicate DWORD length + ms.WriteByte(0xFF); + + // Claim a length that exceeds the remaining buffer + // The remaining buffer after reading stateId + 0xFF + 4-byte-len will be 1 byte, + // but we claim 999 bytes + byte[] claimedLen = BitConverter.GetBytes(999); + ms.Write(claimedLen, 0, 4); + + // Only provide 1 byte of actual data + ms.WriteByte(0x42); + + innerData = ms.ToArray(); + } + + // Feature data length (uint32) + byte[] featureDataLen = BitConverter.GetBytes((uint)innerData.Length); + destination.Write(featureDataLen, 0, 4); + + // Feature data + destination.Write(innerData, 0, innerData.Length); + + // Terminator + destination.WriteByte((byte)TDSFeatureID.Terminator); // 0xFF + } + } + + /// + /// Writes a FedAuthInfo token (0xEE) with a fraudulently large total length. + /// Wire format: [0xEE][int32 tokenLen][...data...] + /// The bounds check fires on tokenLen before any data is read. + /// + private sealed class MaliciousFedAuthInfoToken : TDSPacketToken + { + private readonly int _claimedLength; + + public MaliciousFedAuthInfoToken(int claimedLength) + { + _claimedLength = claimedLength; + } + + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // Token type + destination.WriteByte(0xEE); // SQLFEDAUTHINFO + + // Token length (int32) — fraudulently large + byte[] lenBytes = BitConverter.GetBytes(_claimedLength); + destination.Write(lenBytes, 0, 4); + + // Write minimal data (at least sizeof(uint) to pass the lower bound check, + // but the upper bound check should fire first) + destination.Write(new byte[] { 0x01, 0x00, 0x00, 0x00 }, 0, 4); // optionsCount = 1 + } + } + + /// + /// Writes an EnvChange token (0xE3) with type PromoteTransaction (15) whose + /// inner int32 newLength exceeds . + /// Wire format: [0xE3][uint16 tokenLen][byte type=15][int32 newLen][data...][byte oldLen=0] + /// The outer uint16 tokenLen is set to accommodate the header but the int32 + /// newLength claims far more data than actually follows, triggering the bounds check. + /// + private sealed class MaliciousPromoteTransactionEnvChangeToken : TDSPacketToken + { + private readonly int _claimedNewLength; + + public MaliciousPromoteTransactionEnvChangeToken(int claimedNewLength) + { + _claimedNewLength = claimedNewLength; + } + + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // Token type: ENVCHANGE + destination.WriteByte((byte)TDSTokenType.EnvironmentChange); // 0xE3 + + // Outer token length (uint16): type(1) + newLength(4) + 1 byte data + oldLength(1) + // We write just enough to contain the header fields the parser reads before + // hitting the bounds check. The parser reads: type(1) + int32 newLen(4) = 5 bytes min. + ushort outerLength = 1 + 4 + 1 + 1; // type + newLen + 1 fake byte + oldLen + byte[] outerLenBytes = BitConverter.GetBytes(outerLength); + destination.Write(outerLenBytes, 0, 2); + + // Env change type: PromoteTransaction = 15 + destination.WriteByte(15); + + // newLength (int32) — fraudulently large + byte[] newLenBytes = BitConverter.GetBytes(_claimedNewLength); + destination.Write(newLenBytes, 0, 4); + + // Write 1 byte of fake data (the bounds check fires before attempting to read this much) + destination.WriteByte(0x00); + + // Old value length (byte) = 0 + destination.WriteByte(0x00); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 6: Post-login batch response injection — PromoteTransaction + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that bounds checks fire during command execution (post-login) + /// by injecting a malicious PromoteTransaction env change token into the + /// SQL batch response. This exercises the OnSQLBatchCompleted hook + /// on the simulated server and proves the same bounds check fires regardless + /// of whether the token arrives during login or command execution. + /// + [Fact] + public void BatchResponse_PromoteTransaction_OversizedLength_ThrowsParsingError() + { + _server.OnSQLBatchCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + responseMessage.Insert(doneIndex, new MaliciousPromoteTransactionEnvChangeToken( + claimedNewLength: TdsEnums.MaxPromoteTransactionLength + 1)); + }; + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + + Exception ex = Assert.ThrowsAny( + () => command.ExecuteNonQuery()); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains($"Length: {TdsEnums.MaxPromoteTransactionLength + 1}", ex.Message); + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 7: Post-login batch response — DateTime oversized length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryReadSqlDateTime rejects a TIME column value whose + /// data length byte exceeds the maximum datetime wire size (10 bytes). A + /// malicious server could set this length to a large value, causing the + /// parser to attempt an unbounded heap allocation. + /// + [Fact] + public void BatchResponse_DateTime_OversizedLength_ThrowsParsingError() + { + _server.OnSQLBatchCompleted = responseMessage => + { + // Replace the entire response with a crafted result set containing + // a TIME column whose ROW data length exceeds the maximum. + responseMessage.Clear(); + + // Use proper library tokens for COLMETADATA so framing is correct + var metadata = new TDSColMetadataToken(); + var col = new TDSColumnData(); + col.DataType = TDSDataType.TimeN; + col.DataTypeSpecific = (byte)7; // scale = 7 + col.Flags.IsNullable = true; + col.Name = string.Empty; + metadata.Columns.Add(col); + responseMessage.Add(metadata); + + // Add a malicious ROW token with oversized data length + responseMessage.Add(new MaliciousTimeRowToken()); + + // Add DONE token + responseMessage.Add(new TDSDoneToken(TDSDoneTokenStatusType.Final | TDSDoneTokenStatusType.Count, TDSDoneTokenCommandType.Select, 1)); + }; + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "MALICIOUS_QUERY_NOT_RECOGNIZED"; + + SqlDataReader reader = command.ExecuteReader(); + try + { + // Read() returns true (data is ready) but doesn't actually parse the + // column values — that happens on GetValue/GetTimeSpan. Force the read. + Assert.True(reader.Read()); + Exception ex = Assert.ThrowsAny( + () => reader.GetValue(0)); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains("Length: 11", ex.Message); + } + finally + { + // Disposing the reader after a corrupted stream causes the driver to + // attempt further TDS parsing during teardown, which can trip unrelated + // Debug.Assert calls in TdsParser. + using (new DebugAssertSuppressor()) + { + try { reader.Dispose(); } catch { } + } + } + } + + /// + /// Writes a ROW token (0xD1) with a single TimeN column whose data length + /// byte is set to 11 (exceeding the maximum datetime wire size of 10 bytes). + /// + private sealed class MaliciousTimeRowToken : TDSPacketToken + { + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // ROW token type + destination.WriteByte(0xD1); + + // TimeN data: length prefix (1 byte) = 11 (INVALID — max for time is 5, max datetime overall is 10) + destination.WriteByte(11); + + // Write 11 bytes of dummy data + destination.Write(new byte[11], 0, 11); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 8: Post-login batch response — ReturnValue with oversized data length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessReturnValue rejects a RETURNVALUE token (0xAC) + /// whose inner data length (for a non-PLP IMAGE type) exceeds int.MaxValue. + /// A malicious server can craft a TEXT/IMAGE return value with a spoofed int32 + /// data length that becomes a huge value when cast to ulong, triggering unbounded + /// allocation. + /// + [Fact] + public void BatchResponse_ReturnValue_OversizedLength_ThrowsParsingError() + { + _server.OnSQLBatchCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + responseMessage.Insert(doneIndex, new MaliciousReturnValueToken()); + }; + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + + // The malicious RETURNVALUE token corrupts parser state before the + // exception propagates. Suppress Debug.Assert calls that fire in + // TdsParser during error handling and connection teardown. + using (new DebugAssertSuppressor()) + { + Exception ex = Assert.ThrowsAny( + () => command.ExecuteNonQuery()); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains("Length: -1", ex.Message); + } + } + + /// + /// Writes a RETURNVALUE token (0xAC) with an IMAGE (0x22) type whose data + /// length field is set to -1 (0xFFFFFFFF). When cast to ulong this exceeds + /// int.MaxValue, triggering the bounds check in TryProcessReturnValue. + /// Wire layout: + /// [0xAC] token + /// [uint16] ordinal + /// [byte] param name length (0) + /// [byte] status + /// [uint32] user type + /// [byte] flags1 + /// [byte] flags2 + /// [byte] tds type = 0x22 (IMAGE) + /// [int32] max length + /// [byte] textPtrLen = 16 + /// [16 bytes] textPtr + /// [8 bytes] timestamp + /// [int32] data length = -1 (INVALID) + /// + private sealed class MaliciousReturnValueToken : TDSPacketToken + { + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // RETURNVALUE token + destination.WriteByte(0xAC); + + // Ordinal (uint16 LE) + destination.WriteByte(0x00); + destination.WriteByte(0x00); + + // Param name length (byte) = 0 + destination.WriteByte(0x00); + + // Status (byte) = 0x01 (output parameter) + destination.WriteByte(0x01); + + // UserType (uint32 LE) = 0 + destination.Write(new byte[4], 0, 4); + + // Flags byte 1 (ignored) + destination.WriteByte(0x00); + + // Flags byte 2 + destination.WriteByte(0x00); + + // TDS type = SQLIMAGE (0x22) + destination.WriteByte(0x22); + + // MaxLen (int32 LE) — for IMAGE this is read via TryGetTokenLength + // which for 0x22 reads int32. Value doesn't matter much, just needs + // to be valid for MetaType lookup. + destination.Write(new byte[] { 0x10, 0x00, 0x00, 0x00 }, 0, 4); // 16 + + // -- TryProcessColumnHeaderNoNBC: IsLong && !IsPlp path -- + // TextPtr length (byte) = 16 + destination.WriteByte(0x10); + + // TextPtr data (16 bytes) + destination.Write(new byte[16], 0, 16); + + // Timestamp (8 bytes) + destination.Write(new byte[8], 0, 8); + + // Data length (int32 LE) = -1 (0xFFFFFFFF) + // (ulong)(-1) = 0xFFFFFFFFFFFFFFFF > int.MaxValue → triggers bounds check + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 9: Post-login batch response — PLP ReturnValue (regression guard) + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryProcessReturnValue correctly handles PLP + /// (Partially Length-Prefixed) return values without triggering the bounds + /// check. PLP types use the unknown-length sentinel (0xFFFFFFFFFFFFFFFE) + /// which must NOT be rejected by the non-PLP bounds check. + /// + [Fact] + public void BatchResponse_ReturnValue_PlpUnknownLength_Succeeds() + { + _server.OnSQLBatchCompleted = responseMessage => + { + int doneIndex = responseMessage.FindIndex(t => t is TDSDoneToken); + if (doneIndex < 0) + { + doneIndex = responseMessage.Count; + } + + responseMessage.Insert(doneIndex, new ValidPlpReturnValueToken()); + }; + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + + // Should NOT throw — PLP unknown-length sentinel is valid + command.ExecuteNonQuery(); + } + + /// + /// Writes a valid RETURNVALUE token (0xAC) with a PLP VARBINARY(MAX) type + /// using the unknown-length sentinel (0xFFFFFFFFFFFFFFFE) followed by an + /// immediate PLP terminator (chunk length = 0). This exercises the IsPlp + /// branch in TryProcessReturnValue and must NOT trigger the bounds check. + /// Wire layout: + /// [0xAC] token + /// [uint16] ordinal + /// [byte] param name length (0) + /// [byte] status + /// [uint32] user type + /// [byte] flags1 + /// [byte] flags2 + /// [byte] tds type = 0xA5 (BIGVARBINARY) + /// [uint16] max length = 0xFFFF (PLP marker) + /// [uint64] PLP length = 0xFFFFFFFFFFFFFFFE (unknown) + /// [uint32] chunk length = 0 (terminator) + /// + private sealed class ValidPlpReturnValueToken : TDSPacketToken + { + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // RETURNVALUE token + destination.WriteByte(0xAC); + + // Ordinal (uint16 LE) + destination.WriteByte(0x00); + destination.WriteByte(0x00); + + // Param name length (byte) = 0 + destination.WriteByte(0x00); + + // Status (byte) = 0x01 (output parameter) + destination.WriteByte(0x01); + + // UserType (uint32 LE) = 0 + destination.Write(new byte[4], 0, 4); + + // Flags byte 1 (ignored) + destination.WriteByte(0x00); + + // Flags byte 2 + destination.WriteByte(0x00); + + // TDS type = SQLBIGVARBINARY (0xA5) + destination.WriteByte(0xA5); + + // MaxLen (uint16 LE) = 0xFFFF — PLP marker + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + + // -- TryProcessColumnHeaderNoNBC: non-IsLong path -- + // TryGetDataLength → TryReadPlpLength: + // reads uint64 = 0xFFFFFFFFFFFFFFFE (unknown length sentinel) + destination.WriteByte(0xFE); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + destination.WriteByte(0xFF); + + // PLP chunk terminator (uint32 = 0) — empty data + destination.Write(new byte[4], 0, 4); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 10: Post-login batch response — sql_variant with oversized binary + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryReadSqlValueInternal rejects a binary value inside + /// a sql_variant column whose inner data length exceeds + /// (8000 bytes). The bounds check in the + /// sql_variant deserialization path prevents unbounded heap allocation. + /// + [Fact] + public void BatchResponse_SqlVariantBinary_OversizedLength_ThrowsParsingError() + { + _server.OnSQLBatchCompleted = responseMessage => + { + responseMessage.Clear(); + + // COLMETADATA: one SSVariant column + var metadata = new TDSColMetadataToken(); + var col = new TDSColumnData(); + col.DataType = TDSDataType.SSVariant; + col.DataTypeSpecific = (uint)8009; // max length for SSVariant + col.Flags.IsNullable = true; + col.Name = string.Empty; + metadata.Columns.Add(col); + responseMessage.Add(metadata); + + // ROW with a sql_variant containing oversized binary data + responseMessage.Add(new MaliciousSqlVariantBinaryRowToken()); + + // DONE + responseMessage.Add(new TDSDoneToken(TDSDoneTokenStatusType.Final | TDSDoneTokenStatusType.Count, TDSDoneTokenCommandType.Select, 1)); + }; + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "MALICIOUS_QUERY_NOT_RECOGNIZED"; + + SqlDataReader reader = command.ExecuteReader(); + try + { + Assert.True(reader.Read()); + Exception ex = Assert.ThrowsAny( + () => reader.GetValue(0)); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains("Length: 8001", ex.Message); + } + finally + { + // Disposing the reader after a corrupted stream causes the driver to + // attempt further TDS parsing during teardown, which can trip unrelated + // Debug.Assert calls in TdsParser. + using (new DebugAssertSuppressor()) + { + try { reader.Dispose(); } catch { } + } + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Test 11: Post-login batch response — sql_variant with negative inner length + // ────────────────────────────────────────────────────────────────────────── + + /// + /// Verifies that TryReadSqlValueInternal rejects a sql_variant binary + /// value whose declared total length is too small for the type overhead, + /// causing the computed inner data length to be negative. The bounds check + /// if (length < 0 || length > TdsEnums.MAXSIZE) catches this. + /// + [Fact] + public void BatchResponse_SqlVariantBinary_NegativeLength_ThrowsParsingError() + { + _server.OnSQLBatchCompleted = responseMessage => + { + responseMessage.Clear(); + + // COLMETADATA: one SSVariant column + var metadata = new TDSColMetadataToken(); + var col = new TDSColumnData(); + col.DataType = TDSDataType.SSVariant; + col.DataTypeSpecific = (uint)8009; // max length for SSVariant + col.Flags.IsNullable = true; + col.Name = string.Empty; + metadata.Columns.Add(col); + responseMessage.Add(metadata); + + // ROW with a sql_variant claiming a total length too small for its overhead + responseMessage.Add(new NegativeLenSqlVariantBinaryRowToken()); + + // DONE + responseMessage.Add(new TDSDoneToken(TDSDoneTokenStatusType.Final | TDSDoneTokenStatusType.Count, TDSDoneTokenCommandType.Select, 1)); + }; + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "MALICIOUS_QUERY_NOT_RECOGNIZED"; + + SqlDataReader reader = command.ExecuteReader(); + try + { + Assert.True(reader.Read()); + Exception ex = Assert.ThrowsAny( + () => reader.GetValue(0)); + Assert.Contains("Error state: 18", ex.Message); // CorruptedTdsStream + Assert.Contains("Length: -1", ex.Message); + } + finally + { + // Disposing the reader after a corrupted stream causes the driver to + // attempt further TDS parsing during teardown, which can trip unrelated + // Debug.Assert calls in TdsParser. + using (new DebugAssertSuppressor()) + { + try { reader.Dispose(); } catch { } + } + } + } + + /// + /// Writes a ROW token (0xD1) with a single SSVariant column containing a + /// BigVarBinary variant whose inner data length exceeds MAXSIZE (8000). + /// Wire layout for the variant: + /// [int32] total variant length = 8005 + /// [byte] inner type = 0xA5 (BigVarBinary) + /// [byte] cbPropBytes = 2 + /// [ushort] maxLen (property) = 8001 + /// [8001 bytes would be data, but we only write 4 to trigger the check] + /// lenData = 8005 - 2(SQLVARIANT_SIZE) - 2(cbProps) = 8001 > MAXSIZE → throws + /// + private sealed class MaliciousSqlVariantBinaryRowToken : TDSPacketToken + { + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // ROW token type + destination.WriteByte(0xD1); + + // SSVariant column data: total length (int32 LE) + // lenData = totalLength - SQLVARIANT_SIZE(2) - cbPropBytes(2) = totalLength - 4 + // We want lenData = 8001, so totalLength = 8005 + int totalLength = 8005; + byte[] lenBytes = BitConverter.GetBytes(totalLength); + destination.Write(lenBytes, 0, 4); + + // Inner type: BigVarBinary = 0xA5 + destination.WriteByte(0xA5); + + // cbPropBytes = 2 + destination.WriteByte(0x02); + + // Properties: maxLen (ushort) = 8001 + destination.WriteByte(0x41); // 8001 & 0xFF = 0x41 + destination.WriteByte(0x1F); // 8001 >> 8 = 0x1F + + // Write 4 bytes of dummy data (bounds check fires before trying to read 8001) + destination.Write(new byte[4], 0, 4); + } + } + + /// + /// Writes a ROW token (0xD1) with a single SSVariant column containing a + /// BigVarBinary variant whose total length (3) is too small to cover the + /// type overhead (SQLVARIANT_SIZE=2 + cbPropBytes=2 = 4), producing a + /// negative computed inner data length: lenData = 3 - 4 = -1. + /// + private sealed class NegativeLenSqlVariantBinaryRowToken : TDSPacketToken + { + public override bool Inflate(Stream source) => throw new NotSupportedException(); + + public override void Deflate(Stream destination) + { + // ROW token type + destination.WriteByte(0xD1); + + // SSVariant column data: total length (int32 LE) + // lenConsumed = SQLVARIANT_SIZE(2) + cbPropBytes(2) = 4 + // lenData = totalLength - lenConsumed = 3 - 4 = -1 → triggers < 0 check + int totalLength = 3; + byte[] lenBytes = BitConverter.GetBytes(totalLength); + destination.Write(lenBytes, 0, 4); + + // Inner type: BigVarBinary = 0xA5 + destination.WriteByte(0xA5); + + // cbPropBytes = 2 + destination.WriteByte(0x02); + + // Properties: maxLen (ushort) = 100 (arbitrary, just need 2 bytes) + destination.WriteByte(0x64); // 100 & 0xFF + destination.WriteByte(0x00); // 100 >> 8 + + // No data bytes — the bounds check fires before attempting to read + } + } + + /// + /// Temporarily suppresses Debug.Assert failures by clearing trace listeners. Used when + /// disposing resources after intentionally corrupting a TDS stream. A static lock serializes + /// access for the lifetime of the instance because Trace.Listeners is a global collection. + /// + private sealed class DebugAssertSuppressor : IDisposable + { + private static readonly object s_listenerLock = new(); + private readonly System.Diagnostics.TraceListener[] _listeners; + + public DebugAssertSuppressor() + { + System.Threading.Monitor.Enter(s_listenerLock); + try + { + _listeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count]; + System.Diagnostics.Trace.Listeners.CopyTo(_listeners, 0); + System.Diagnostics.Trace.Listeners.Clear(); + } + catch + { + System.Threading.Monitor.Exit(s_listenerLock); + throw; + } + } + + public void Dispose() + { + try + { + System.Diagnostics.Trace.Listeners.Clear(); + System.Diagnostics.Trace.Listeners.AddRange(_listeners); + } + finally + { + System.Threading.Monitor.Exit(s_listenerLock); + } + } + } +} 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 d2c81e6ca1..3ec8f6efaf 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 @@ -139,6 +139,13 @@ public GenericTdsServer(T arguments, QueryEngine queryEngine) public OnAuthenticationCompletedDelegate OnAuthenticationResponseCompleted { private get; set; } + /// + /// Delegate invoked after a SQL batch response is prepared but before it is + /// sent to the client. Tests can use this to inject or replace tokens in the + /// response message. + /// + public Action OnSQLBatchCompleted { get; set; } + public OnLogin7ValidatedDelegate OnLogin7Validated { private get; set; } @@ -451,6 +458,9 @@ public virtual TDSMessageCollection OnSQLBatchRequest(ITDSServerSession session, session.PacketSize = (uint)Arguments.PacketSize; } + // Allow tests to modify or inject tokens into the response + OnSQLBatchCompleted?.Invoke(responseMessage[0]); + return responseMessage; } diff --git a/tools/props/Versions.props b/tools/props/Versions.props index 574299da81..16ce7defda 100644 --- a/tools/props/Versions.props +++ b/tools/props/Versions.props @@ -31,7 +31,7 @@ - 7.0.1 + 7.0.2 $(MdsVersionDefault).$(BuildNumber)-dev @@ -67,4 +67,29 @@ + + + + 6.0.2 + [$(SniVersion), $([MSBuild]::Add($(SniVersion.Split('.')[0]), 1)).0.0) + [1.0.0, 2.0.0) + [$(AbstractionsPackageVersion), $([MSBuild]::Add($(AbstractionsPackageVersion.Trim().Split('.')[0]), 1)).0.0) + [$(LoggingPackageVersion), $([MSBuild]::Add($(LoggingPackageVersion.Trim().Split('.')[0]), 1)).0.0) + [$(MdsPackageVersion), $([MSBuild]::Add($(MdsPackageVersion.Trim().Split('.')[0]), 1)).0.0) + [$(AzurePackageVersion), $([MSBuild]::Add($(AzurePackageVersion.Trim().Split('.')[0]), 1)).0.0) + [$(AkvPackageVersion), $([MSBuild]::Add($(AkvPackageVersion.Trim().Split('.')[0]), 1)).0.0) + + diff --git a/tools/scripts/downloadLatestNuget.ps1 b/tools/scripts/downloadLatestNuget.ps1 deleted file mode 100644 index faddf3bb09..0000000000 --- a/tools/scripts/downloadLatestNuget.ps1 +++ /dev/null @@ -1,27 +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. -# Script: downloadLatestNuget.ps1 -# Author: Keerat Singh -# Date: 07-Dec-2018 -# Comments: This script downloads the latest NuGet Binary. -# -param( - [string]$nugetDestPath, - [string]$nugetSrcPath="https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -) -Function DownloadLatestNuget() -{ - if(!$nugetDestPath) - { - $nugetDestPath = (Get-location).ToString() +'\.nuget\' - } - if (!(Test-Path $nugetDestPath)) - { - New-Item -ItemType Directory -Path $nugetDestPath - } - Write-Output "Source: $nugetSrcPath" - Write-Output "Destination: $nugetDestPath" - Start-BitsTransfer -Source $nugetSrcPath -Destination $nugetDestPath\nuget.exe -} -DownloadLatestNuget \ No newline at end of file diff --git a/tools/specs/Microsoft.Data.SqlClient.nuspec b/tools/specs/Microsoft.Data.SqlClient.nuspec index 117af2dcb2..426548984b 100644 --- a/tools/specs/Microsoft.Data.SqlClient.nuspec +++ b/tools/specs/Microsoft.Data.SqlClient.nuspec @@ -29,17 +29,18 @@ sqlclient microsoft.data.sqlclient - - - + + + @@ -60,37 +61,37 @@ - - - + + + - + - - - + + + - + - - - + + + - + diff --git a/tools/targets/GenerateMdsPackage.targets b/tools/targets/GenerateMdsPackage.targets index b4bf4a0d5c..d77b175fec 100644 --- a/tools/targets/GenerateMdsPackage.targets +++ b/tools/targets/GenerateMdsPackage.targets @@ -5,10 +5,23 @@ - + - - + + + + + + + +