From 4c2ddd40afb3c4d5046737aa6e6f6cf44c2d6395 Mon Sep 17 00:00:00 2001 From: Jens Theisen Date: Fri, 9 Oct 2026 23:00:52 +0000 Subject: [PATCH] Fix ANSI parameter MAX promotion for multibyte encodings Use the connection encoding to count transmitted ANSI bytes before selecting MAX metadata. This prevents UTF-8 values below 8000 characters but above 8000 bytes from producing invalid VARCHAR declarations and RPC framing. Cover encoded byte boundaries, inferred sizes, truncation, offsets and SqlString values with unit regressions. Add SQL Server round-trip tests for text, stored procedure and prepared commands with sync and async execution. Validation: 1502 unit tests and 206 parameter functional tests passed; the Windows-only integrated authentication test was excluded on Linux. All 19 wire scenarios passed, including replay of the original EF command. Driver builds passed for net10.0 and net462. Live SQL Server tests compile but remain unverified locally. Fixes #2035 --- .../Microsoft/Data/SqlClient/SqlCommand.cs | 4 +- .../Microsoft/Data/SqlClient/SqlParameter.cs | 16 ++- .../SQL/Utf8SupportTest/Utf8ParameterTests.cs | 69 +++++++++++ .../UnitTests/SqlCommandAnsiParameterTests.cs | 114 ++++++++++++++++++ 4 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs 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 7757927203..3d494dfc57 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs @@ -2248,7 +2248,7 @@ private string BuildParamList(TdsParser parser, SqlParameterCollection parameter // Func will change type to that with a 4 byte length if the type has a two // byte length and a parameter length > that expressible in 2 bytes. // @TODO: what func? - mt = sqlParam.ValidateTypeLengths(); + mt = sqlParam.ValidateTypeLengths(parser); if (!mt.IsPlp && sqlParam.Direction is not ParameterDirection.Output) { sqlParam.FixStreamDataForNonPLP(); @@ -2805,7 +2805,7 @@ private void SetUpRPCParameters(_SqlRPC rpc, bool inSchema, SqlParameterCollecti // Func will change type to that with a 4 byte length if the type has a 2 byte // length and a parameter length > than that expressible in 2 bytes. - if (!parameter.ValidateTypeLengths().IsPlp && parameter.Direction is not ParameterDirection.Output) + if (!parameter.ValidateTypeLengths(_stateObj.Parser).IsPlp && parameter.Direction is not ParameterDirection.Output) { parameter.FixStreamDataForNonPLP(); } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs index b0fc3fd911..925f94c571 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -2091,7 +2091,7 @@ internal void Validate(int index, bool isCommandProc) // func will change type to that with a 4 byte length if the type has a two // byte length and a parameter length > than that expressible in 2 bytes - internal MetaType ValidateTypeLengths() + internal MetaType ValidateTypeLengths(TdsParser parser = null) { MetaType mt = InternalMetaType; // Since the server will automatically reject any @@ -2104,6 +2104,20 @@ internal MetaType ValidateTypeLengths() long actualSizeInBytes = GetActualSize(); long sizeInCharacters = Size; + // GetActualSize returns characters for ANSI strings. Decide whether to use + // MAX before building the SQL declaration or writing the RPC metadata, using + // the same connection encoding and character slice as the serializer. + if (parser != null && mt.IsAnsiType && !IsNull && + actualSizeInBytes <= TdsEnums.TYPE_SIZE_LIMIT && + sizeInCharacters <= TdsEnums.TYPE_SIZE_LIMIT && sizeInCharacters != -1 && + !HasFlag(SqlParameterFlags.CoercedValueIsDataFeed) && Direction != ParameterDirection.Output) + { + object value = GetCoercedValue(); + string text = value is SqlString sqlString ? sqlString.Value : + value is SqlChars sqlChars ? new string(sqlChars.Value) : (string)value; + actualSizeInBytes = parser.GetEncodingCharLength(text, (int)actualSizeInBytes, Offset, null); + } + // Bug: VSTFDevDiv #636867 // Notes: // 'actualSizeInBytes' is the size of value passed; diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs new file mode 100644 index 0000000000..4a4b94f1e0 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs @@ -0,0 +1,69 @@ +// 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.Data; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// + /// Verifies that UTF-8 RPC values crossing 8000 bytes round-trip without malformed metadata or data loss. + /// + [Trait("Set", "3")] + public static class Utf8ParameterTests + { + /// + /// Covers text, stored-procedure and prepared RPCs with both synchronous and asynchronous execution. + /// + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsUTF8Supported), nameof(DataTestUtility.IsNotAzureServer), nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 2)] + [InlineData(true, 2)] + public static async Task VarCharUtf8ByteBoundaryRoundTrip(bool async, int mode) + { + string database = DataTestUtility.GetLongName("Utf8Parameter", false); + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) { InitialCatalog = "master" }; + using SqlConnection admin = DataTestUtility.CreateConnection(builder.ConnectionString); + admin.Open(); + using (SqlCommand create = admin.CreateCommand()) + { + create.CommandText = $"CREATE DATABASE [{database}] COLLATE Latin1_General_100_CI_AS_SC_UTF8"; + create.ExecuteNonQuery(); + } + try + { + builder.InitialCatalog = database; + using SqlConnection connection = DataTestUtility.CreateConnection(builder.ConnectionString); + if (async) await connection.OpenAsync(); + else connection.Open(); + using StoredProcedure procedure = new(connection, "Utf8Echo", "@p varchar(max) AS SELECT @p"); + using SqlCommand command = connection.CreateCommand(); + command.CommandText = mode == 1 ? procedure.Name : "SELECT @p"; + command.CommandType = mode == 1 ? CommandType.StoredProcedure : CommandType.Text; + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, 8000); + parameter.Value = new string('é', 4001); + if (mode == 2) command.Prepare(); + + // Reuse the command across the byte boundary and back, including an existing prepared handle. + foreach (int count in new[] { 4001, 4000, 4001 }) + { + string text = new('é', count); + parameter.Value = text; + object result = async ? await command.ExecuteScalarAsync() : command.ExecuteScalar(); + Assert.Equal(text, result); + } + } + finally + { + DataTestUtility.DropDatabase(admin, database); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs new file mode 100644 index 0000000000..1ad170d3b8 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs @@ -0,0 +1,114 @@ +// 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.Data; +using System.Data.SqlTypes; +using System.Reflection; +using System.Text; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Guards ANSI parameter declarations against exceeding TDS's byte limit under multibyte encodings. + /// + public class SqlCommandAnsiParameterTests + { + /// + /// Exercises the declaration path used by sp_executesql and prepared commands at UTF-8 boundaries. + /// + [Theory] + [InlineData("é", 4000, 8000, false, false)] + [InlineData("é", 4001, 8000, false, true)] + [InlineData("é", 4001, 0, false, true)] + [InlineData("€", 2666, 8000, false, false)] + [InlineData("€", 2667, 8000, false, true)] + [InlineData("😀", 2001, 8000, false, true)] + [InlineData("a", 8000, 8000, false, false)] + [InlineData("é", 4001, 4000, false, false)] + [InlineData("é", 4001, -1, false, true)] + [InlineData("é", 4001, 8000, true, true)] + public void BuildParamList_UsesEncodedByteCount(string character, int count, int size, bool sqlString, bool expectMax) + { + string text = string.Concat(System.Linq.Enumerable.Repeat(character, count)); + SqlParameter parameter = new("@p", SqlDbType.VarChar, size) + { + Value = sqlString ? new SqlString(text) : (object)text + }; + string declaration = BuildDeclaration(parameter, Encoding.UTF8); + + Assert.Equal(expectMax, parameter.InternalMetaType.IsPlp); + if (expectMax) + { + Assert.Contains("varchar(max)", declaration); + } + else + { + Assert.DoesNotContain("(max)", declaration); + int encodedBytes = Encoding.UTF8.GetByteCount(text.Substring(0, parameter.GetActualSize())); + Assert.Contains($"varchar({Math.Max(size, encodedBytes)})", declaration); + } + // GetActualSize's ANSI character-count contract must remain intact for serialization. + Assert.Equal(size > 0 ? Math.Min(size, text.Length) : text.Length, parameter.GetActualSize()); + } + + /// + /// Promotion uses the connection encoding rather than assuming all ANSI values are UTF-8. + /// + [Fact] + public void BuildParamList_SingleByteEncoding_DoesNotPromote() + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, 8000) { Value = new string('é', 4001) }; + Assert.Contains("varchar(8000)", BuildDeclaration(parameter, Encoding.GetEncoding(28591))); + Assert.False(parameter.InternalMetaType.IsPlp); + } + + /// + /// Truncation and offset select which characters determine the encoded byte count. + /// + [Fact] + public void BuildParamList_Offset_UsesTransmittedSlice() + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, 4001) + { + Value = new string('a', 4001) + new string('é', 4001), + Offset = 4001 + }; + Assert.Contains("varchar(max)", BuildDeclaration(parameter, Encoding.UTF8)); + } + + /// + /// Null parameters must not require conversion to a string for the byte-count check. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BuildParamList_NullValue_DoesNotPromote(bool sqlNull) + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, 8000) + { + Value = sqlNull ? SqlString.Null : DBNull.Value + }; + Assert.Contains("varchar(8000)", BuildDeclaration(parameter, Encoding.UTF8)); + Assert.False(parameter.InternalMetaType.IsPlp); + } + + /// + /// Calls the real SQL declaration builder with a connection's negotiated encoding, without a server. + /// + /// The parameter to validate and declare. + /// The encoding negotiated by the connection. + /// The SQL declaration emitted for the parameter. + private static string BuildDeclaration(SqlParameter parameter, Encoding encoding) + { + TdsParser parser = new(false, false); + typeof(TdsParser).GetField("_defaultEncoding", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, encoding); + using SqlCommand command = new(); + command.Parameters.Add(parameter); + return (string)typeof(SqlCommand).GetMethod("BuildParamList", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(command, new object[] { parser, command.Parameters, false })!; + } + } +}