Skip to content

Commit 243663b

Browse files
committed
Fix shipped-behavior fidelity bugs: REPLICATE now preserves MAX for varchar(max)/nvarchar(max) column inputs (read at runtime, not a parse-time probe that threw on FROM-source columns), GROUPING/GROUPING_ID match GROUP BY expressions structurally (paren-stripped) instead of Reference-only, and STRING_SPLIT rejects all variable-bearing enable_ordinal shapes with Msg 8748.
Add FORMATMESSAGE (printf-subset interpreter), PWDENCRYPT/PWDCOMPARE (real SQL Server 2025 0x0300 PBKDF2-HMAC-SHA512 format), and LOGINPROPERTY (fixed-principal seed).
1 parent d99b13a commit 243663b

20 files changed

Lines changed: 1278 additions & 90 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
138138

139139
Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger: read the linked file on demand when working in the matching area.
140140

141-
- **Built-in scalars** (math, date incl. DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET, current-time, `*FROMPARTS`, AT TIME ZONE, CONCAT, char-code, SOUNDEX / STR / TRANSLATE / STRING_ESCAPE / DIFFERENCE, CHOOSE / IIF, bit manipulation (BIT_COUNT / GET_BIT / SET_BIT / LEFT_SHIFT / RIGHT_SHIFT), CHECKSUM / BINARY_CHECKSUM, FORMAT, RAND, STRING_SPLIT, GENERATE_SERIES, COMPRESS/DECOMPRESS, session/server `@@`-constants + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME, session-state store (SESSION_CONTEXT / sp_set_session_context / CONTEXT_INFO / CONNECTIONPROPERTY / CURRENT_TRANSACTION_ID / CURRENT_REQUEST_ID)) → [`scalars.md`](docs/claude/scalars.md).
141+
- **Built-in scalars** (math, date incl. DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET, current-time, `*FROMPARTS`, AT TIME ZONE, CONCAT, char-code, SOUNDEX / STR / TRANSLATE / STRING_ESCAPE / DIFFERENCE, CHOOSE / IIF, bit manipulation (BIT_COUNT / GET_BIT / SET_BIT / LEFT_SHIFT / RIGHT_SHIFT), CHECKSUM / BINARY_CHECKSUM, FORMAT, FORMATMESSAGE, RAND, STRING_SPLIT, GENERATE_SERIES, COMPRESS/DECOMPRESS, PWDENCRYPT / PWDCOMPARE, LOGINPROPERTY, session/server `@@`-constants + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME, session-state store (SESSION_CONTEXT / sp_set_session_context / CONTEXT_INFO / CONNECTIONPROPERTY / CURRENT_TRANSACTION_ID / CURRENT_REQUEST_ID)) → [`scalars.md`](docs/claude/scalars.md).
142142
- **`SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale / int↔string promotion**[`arithmetic.md`](docs/claude/arithmetic.md).
143143
- **`Cast` / coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set, PARSE/TRY_PARSE culture-aware parsing) → [`casting.md`](docs/claude/casting.md).
144144
- **`Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`query.md`](docs/claude/query.md).
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for the <c>FORMATMESSAGE</c>, <c>PWDENCRYPT</c> /
8+
/// <c>PWDCOMPARE</c>, and <c>LOGINPROPERTY</c> scalar built-ins. All expected
9+
/// values probe-confirmed against SQL Server 2025 (2026-07-10).
10+
/// </summary>
11+
[TestClass]
12+
public sealed class FormatMessageAndLoginScalarTests
13+
{
14+
/// <summary>
15+
/// The terse in-server formatting-error diagnostic returned (as data, not
16+
/// thrown) for a recoverable FORMATMESSAGE failure. Byte-exact from the
17+
/// live server, trailing CRLF included.
18+
/// </summary>
19+
private const string TerseError =
20+
"Error: 50000, Severity: -1, State: 1. (Params:). The error is printed in terse mode because there was error during formatting. Tracing, ETW, notifications etc are skipped.\r\n";
21+
22+
private static object? Scalar(string sql) => new Simulation().ExecuteScalar(sql);
23+
24+
private static void AssertMsg(string sql, int errorNumber)
25+
{
26+
var ex = Throws<DbException>(() => Scalar(sql));
27+
AreEqual(errorNumber.ToString(), ex.Data["HelpLink.EvtID"], $"expected Msg {errorNumber}");
28+
}
29+
30+
// ---- FORMATMESSAGE: core specifiers ----
31+
32+
[TestMethod]
33+
public void FormatMessage_StringAndInt()
34+
=> AreEqual("hi world and 42", Scalar("select formatmessage('hi %s and %d', 'world', 42)"));
35+
36+
[TestMethod]
37+
public void FormatMessage_IntSynonym()
38+
=> AreEqual("num 42", Scalar("select formatmessage('num %i', 42)"));
39+
40+
[TestMethod]
41+
public void FormatMessage_HexLowerUpper()
42+
{
43+
AreEqual("hex ff", Scalar("select formatmessage('hex %x', 255)"));
44+
AreEqual("HEX FF", Scalar("select formatmessage('HEX %X', 255)"));
45+
}
46+
47+
[TestMethod]
48+
public void FormatMessage_Octal()
49+
=> AreEqual("oct 100", Scalar("select formatmessage('oct %o', 64)"));
50+
51+
[TestMethod]
52+
public void FormatMessage_UnsignedReinterpretsNegative()
53+
=> AreEqual("uns 4294967295", Scalar("select formatmessage('uns %u', -1)"));
54+
55+
// ---- FORMATMESSAGE: width / flags / precision ----
56+
57+
[TestMethod]
58+
public void FormatMessage_WidthRightAlign()
59+
=> AreEqual("[ 42]", Scalar("select formatmessage('[%5d]', 42)"));
60+
61+
[TestMethod]
62+
public void FormatMessage_WidthLeftAlign()
63+
=> AreEqual("[42 ]", Scalar("select formatmessage('[%-5d]', 42)"));
64+
65+
[TestMethod]
66+
public void FormatMessage_ZeroPad()
67+
=> AreEqual("[00042]", Scalar("select formatmessage('[%05d]', 42)"));
68+
69+
[TestMethod]
70+
public void FormatMessage_ForceSign()
71+
{
72+
AreEqual("[+42]", Scalar("select formatmessage('[%+d]', 42)"));
73+
AreEqual("[-42]", Scalar("select formatmessage('[%+d]', -42)"));
74+
}
75+
76+
[TestMethod]
77+
public void FormatMessage_PrecisionZeroPadsInteger()
78+
=> AreEqual("[005]", Scalar("select formatmessage('[%.3d]', 5)"));
79+
80+
[TestMethod]
81+
public void FormatMessage_AltFormHexPrefix()
82+
=> AreEqual("[0xff]", Scalar("select formatmessage('[%#x]', 255)"));
83+
84+
[TestMethod]
85+
public void FormatMessage_HexWidthZeroPad()
86+
=> AreEqual("[000000FF]", Scalar("select formatmessage('[%08X]', 255)"));
87+
88+
[TestMethod]
89+
public void FormatMessage_StarWidthConsumesArgument()
90+
=> AreEqual("[ 42]", Scalar("select formatmessage('[%*d]', 5, 42)"));
91+
92+
// ---- FORMATMESSAGE: length modifiers ----
93+
94+
[TestMethod]
95+
public void FormatMessage_LongModifierIgnored()
96+
=> AreEqual("val=[42]", Scalar("select formatmessage('val=[%ld]', 42)"));
97+
98+
[TestMethod]
99+
public void FormatMessage_Int64Modifier()
100+
=> AreEqual("val=[9999999999]", Scalar("select formatmessage('val=[%I64d]', cast(9999999999 as bigint))"));
101+
102+
// ---- FORMATMESSAGE: literal / escape / arg-count ----
103+
104+
[TestMethod]
105+
public void FormatMessage_PercentEscape()
106+
=> AreEqual("100% done", Scalar("select formatmessage('100%% done')"));
107+
108+
[TestMethod]
109+
public void FormatMessage_TooFewArgsRendersNull()
110+
=> AreEqual("a one b (null)", Scalar("select formatmessage('a %s b %s', 'one')"));
111+
112+
[TestMethod]
113+
public void FormatMessage_TooManyArgsIgnored()
114+
=> AreEqual("a one", Scalar("select formatmessage('a %s', 'one', 'two')"));
115+
116+
// ---- FORMATMESSAGE: NULL handling ----
117+
118+
[TestMethod]
119+
public void FormatMessage_NullFormatReturnsNull()
120+
=> AreEqual(DBNull.Value, Scalar("select formatmessage(cast(null as nvarchar(100)), 'x')"));
121+
122+
[TestMethod]
123+
public void FormatMessage_NullStringArgRendersNull()
124+
=> AreEqual("val=[(null)]", Scalar("select formatmessage('val=[%s]', cast(null as nvarchar(10)))"));
125+
126+
[TestMethod]
127+
public void FormatMessage_NullIntArgRendersNull()
128+
=> AreEqual("val=[(null)]", Scalar("select formatmessage('val=[%d]', cast(null as int))"));
129+
130+
// ---- FORMATMESSAGE: terse-error recoverable failures ----
131+
132+
[TestMethod]
133+
public void FormatMessage_IntIntoStringSpecifier_Terse()
134+
=> AreEqual(TerseError, Scalar("select formatmessage('val=[%s]', 42)"));
135+
136+
[TestMethod]
137+
public void FormatMessage_StringIntoIntSpecifier_Terse()
138+
=> AreEqual(TerseError, Scalar("select formatmessage('val=[%d]', 'abc')"));
139+
140+
[TestMethod]
141+
public void FormatMessage_BigIntIntoNarrowSpecifier_Terse()
142+
=> AreEqual(TerseError, Scalar("select formatmessage('val=[%d]', cast(9999999999 as bigint))"));
143+
144+
[TestMethod]
145+
public void FormatMessage_IntIntoInt64Specifier_Terse()
146+
=> AreEqual(TerseError, Scalar("select formatmessage('val=[%I64d]', 5)"));
147+
148+
[TestMethod]
149+
public void FormatMessage_EmptyFormat_Terse()
150+
=> AreEqual(TerseError, Scalar("select formatmessage('')"));
151+
152+
// ---- FORMATMESSAGE: Msg 2748 disallowed substitution types ----
153+
154+
[TestMethod]
155+
public void FormatMessage_FloatArg_Msg2748()
156+
=> AssertMsg("select formatmessage('%d', cast(5 as float))", 2748);
157+
158+
[TestMethod]
159+
public void FormatMessage_MoneyArg_Msg2748()
160+
=> AssertMsg("select formatmessage('%d', cast(5 as money))", 2748);
161+
162+
[TestMethod]
163+
public void FormatMessage_DateTimeArg_Msg2748()
164+
=> AssertMsg("select formatmessage('%d', cast('2020-01-01' as datetime))", 2748);
165+
166+
[TestMethod]
167+
public void FormatMessage_Msg2748_MessageWordingAndParamIndex()
168+
{
169+
var ex = Throws<DbException>(() => Scalar("select formatmessage('%d', cast(5 as float))"));
170+
AreEqual("Cannot specify float data type (parameter 1) as a substitution parameter.", ex.Message);
171+
}
172+
173+
// ---- FORMATMESSAGE: msg_id overload + truncation ----
174+
175+
[TestMethod]
176+
public void FormatMessage_UnknownMessageId_ReturnsNull()
177+
{
178+
AreEqual(DBNull.Value, Scalar("select formatmessage(50000, 'x')"));
179+
AreEqual(DBNull.Value, Scalar("select formatmessage(99999999)"));
180+
}
181+
182+
[TestMethod]
183+
public void FormatMessage_ResultTruncatesTo2047Chars()
184+
=> AreEqual(4094, Scalar("select datalength(formatmessage('%s', replicate(cast('a' as nvarchar(max)), 5000)))"));
185+
186+
// ---- PWDENCRYPT / PWDCOMPARE ----
187+
188+
[TestMethod]
189+
public void PwdEncrypt_ProducesSeventyByteHash()
190+
{
191+
var hash = (byte[])new Simulation().ExecuteScalar("select pwdencrypt('abc')")!;
192+
HasCount(70, hash);
193+
AreEqual((byte)0x03, hash[0]);
194+
AreEqual((byte)0x00, hash[1]);
195+
}
196+
197+
[TestMethod]
198+
public void PwdEncrypt_DataLengthIs70()
199+
=> AreEqual(70, Scalar("select datalength(pwdencrypt('abc'))"));
200+
201+
[TestMethod]
202+
public void PwdEncrypt_SamePasswordDiffersBySalt()
203+
{
204+
using var connection = new Simulation().CreateOpenConnection();
205+
var a = (byte[])connection.CreateCommand("select pwdencrypt('abc')").ExecuteScalar()!;
206+
var b = (byte[])connection.CreateCommand("select pwdencrypt('abc')").ExecuteScalar()!;
207+
IsFalse(a.AsSpan().SequenceEqual(b), "salt should randomize successive hashes");
208+
}
209+
210+
[TestMethod]
211+
public void PwdCompare_RoundTripRightPassword()
212+
=> AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'))"));
213+
214+
[TestMethod]
215+
public void PwdCompare_WrongPassword()
216+
=> AreEqual(0, Scalar("select pwdcompare('xyz', pwdencrypt('abc'))"));
217+
218+
[TestMethod]
219+
public void PwdCompare_NullClearReturnsNull()
220+
=> AreEqual(DBNull.Value, Scalar("select pwdcompare(null, pwdencrypt('abc'))"));
221+
222+
[TestMethod]
223+
public void PwdCompare_NullHashReturnsNull()
224+
=> AreEqual(DBNull.Value, Scalar("select pwdcompare('abc', null)"));
225+
226+
[TestMethod]
227+
public void PwdCompare_GarbageShortHashReturnsZero()
228+
=> AreEqual(0, Scalar("select pwdcompare('abc', 0x1234)"));
229+
230+
[TestMethod]
231+
public void PwdCompare_EmptyHashReturnsZero()
232+
=> AreEqual(0, Scalar("select pwdcompare('abc', 0x)"));
233+
234+
[TestMethod]
235+
public void PwdCompare_ThirdVersionArgumentAcceptedAndIgnored()
236+
{
237+
AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'), 0)"));
238+
AreEqual(1, Scalar("select pwdcompare('abc', pwdencrypt('abc'), 1)"));
239+
}
240+
241+
/// <summary>
242+
/// Cross-engine fidelity: a <c>0x0300</c> hash generated by the live
243+
/// SQL Server 2025 reference for password <c>'abc'</c> verifies inside the
244+
/// simulator (probe-captured 2026-07-10). Proves the simulator reproduces
245+
/// SQL Server's real PBKDF2-HMAC-SHA512(UTF-16LE, salt, 100000) layout.
246+
/// </summary>
247+
[TestMethod]
248+
public void PwdCompare_VerifiesRealServerGeneratedHash()
249+
{
250+
const string realHash = "0x030016EEF69E6272D4880A64A024480DC06B67872BF77647CBA42241A5DD922EDCC7495917C2691CFE6255123A27CFBA9F8DE1AA5F0D500F357B7063EAC9ACFBE94D61B8815D";
251+
AreEqual(1, Scalar($"select pwdcompare('abc', {realHash})"));
252+
AreEqual(0, Scalar($"select pwdcompare('wrong', {realHash})"));
253+
}
254+
255+
// ---- LOGINPROPERTY ----
256+
257+
[TestMethod]
258+
public void LoginProperty_KnownLogin_TimeAndCountProperties()
259+
{
260+
AreEqual("2020-01-01 00:00:00.000", Scalar("select loginproperty('dbo', 'PasswordLastSetTime')"));
261+
AreEqual("0", Scalar("select loginproperty('dbo', 'BadPasswordCount')"));
262+
AreEqual("1900-01-01 00:00:00.000", Scalar("select loginproperty('dbo', 'BadPasswordTime')"));
263+
AreEqual("1900-01-01 00:00:00.000", Scalar("select loginproperty('dbo', 'LockoutTime')"));
264+
AreEqual("0", Scalar("select loginproperty('dbo', 'HistoryLength')"));
265+
}
266+
267+
[TestMethod]
268+
public void LoginProperty_KnownLogin_BooleanFlags()
269+
{
270+
AreEqual("0", Scalar("select loginproperty('dbo', 'IsExpired')"));
271+
AreEqual("0", Scalar("select loginproperty('dbo', 'IsLocked')"));
272+
AreEqual("0", Scalar("select loginproperty('dbo', 'IsMustChange')"));
273+
}
274+
275+
[TestMethod]
276+
public void LoginProperty_KnownLogin_NullValuedProperties()
277+
{
278+
AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'DaysUntilExpiration')"));
279+
AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'PasswordHash')"));
280+
AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'PasswordHashAlgorithm')"));
281+
}
282+
283+
[TestMethod]
284+
public void LoginProperty_KnownLogin_NameProperties()
285+
{
286+
AreEqual("simulated", Scalar("select loginproperty('dbo', 'DefaultDatabase')"));
287+
AreEqual("us_english", Scalar("select loginproperty('dbo', 'DefaultLanguage')"));
288+
}
289+
290+
[TestMethod]
291+
public void LoginProperty_CaseInsensitiveLoginAndProperty()
292+
=> AreEqual("0", Scalar("select loginproperty('DBO', 'isexpired')"));
293+
294+
[TestMethod]
295+
public void LoginProperty_UnknownLogin_ReturnsNull()
296+
=> AreEqual(DBNull.Value, Scalar("select loginproperty('no_such_login', 'IsExpired')"));
297+
298+
[TestMethod]
299+
public void LoginProperty_UnknownProperty_ReturnsNull()
300+
=> AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', 'NoSuchProperty')"));
301+
302+
[TestMethod]
303+
public void LoginProperty_NullLogin_ReturnsNull()
304+
=> AreEqual(DBNull.Value, Scalar("select loginproperty(null, 'IsExpired')"));
305+
306+
[TestMethod]
307+
public void LoginProperty_NullProperty_ReturnsNull()
308+
=> AreEqual(DBNull.Value, Scalar("select loginproperty('dbo', null)"));
309+
}

0 commit comments

Comments
 (0)