Skip to content

Commit 6bf2c51

Browse files
committed
sys.dm_hadr_cluster returns a single non-clustered row, SUSER_SID resolves against the login surface, SID_BINARY is constant NULL, and a bare THROW inside a CATCH whose TRY body succeeded no longer raises Msg 10704: the rethrow compile-check is lexical, so skip-dispatching an un-taken CATCH body now bumps CatchDepth.
1 parent 325f6c2 commit 6bf2c51

12 files changed

Lines changed: 209 additions & 4 deletions

SqlServerSimulator.Tests/PrincipalScalarTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,43 @@ public void Combined_AllConverge_ReturnDbo()
8686
=> AreEqual(1, new Simulation().ExecuteScalar("""
8787
select iif(current_user = user_name() and user_name() = original_login() and user = current_user, 1, 0)
8888
"""));
89+
90+
// === SUSER_SID / SID_BINARY ===
91+
92+
[TestMethod]
93+
public void SuserSid_NoArg_ReturnsWellKnownSid()
94+
=> CollectionAssert.AreEqual(new byte[] { 0x01 }, (byte[]?)new Simulation().ExecuteScalar("select suser_sid()"));
95+
96+
[TestMethod]
97+
public void SuserSid_Sa_ReturnsWellKnownSid()
98+
=> CollectionAssert.AreEqual(new byte[] { 0x01 }, (byte[]?)new Simulation().ExecuteScalar("select suser_sid(N'sa')"));
99+
100+
[TestMethod]
101+
public void SuserSid_RegistryLogin_MatchesServerPrincipalsSid()
102+
{
103+
var sim = new Simulation();
104+
_ = sim.ExecuteNonQuery("create login probe_login with password = 'p@ss'");
105+
AreEqual(1, sim.ExecuteScalar(
106+
"select iif(suser_sid(N'probe_login') = (select sid from sys.server_principals where name = N'probe_login'), 1, 0)"));
107+
}
108+
109+
[TestMethod]
110+
public void SuserSid_Unknown_ReturnsNull()
111+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select suser_sid(N'nosuchlogin')"));
112+
113+
[TestMethod]
114+
public void SuserSid_SecondParameter_Accepted()
115+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select suser_sid(N'nosuchlogin', 0)"));
116+
117+
[TestMethod]
118+
public void SidBinary_AlwaysNull_EvenForExistingLogin()
119+
{
120+
// Probe-confirmed against SQL Server 2025: SID_BINARY resolves only
121+
// Windows / Entra-ID directory principals — it returns NULL even for
122+
// an existing SQL-auth login, so constant NULL is faithful here.
123+
var sim = new Simulation();
124+
_ = sim.ExecuteNonQuery("create login probe_login2 with password = 'p@ss'");
125+
AreEqual(DBNull.Value, sim.ExecuteScalar("select sid_binary(N'probe_login2')"));
126+
AreEqual(DBNull.Value, sim.ExecuteScalar("select sid_binary(N'')"));
127+
}
89128
}

SqlServerSimulator.Tests/SsmsProgrammabilityNodeCatalogTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,22 @@ public void DatabaseFiles_ResolvesCrossDatabaseThroughMaster()
176176
AreEqual("master_Log", (string?)sim.ExecuteScalar("select name from master.sys.database_files where file_id = 2"));
177177
}
178178

179+
/// <summary>
180+
/// Probe-confirmed against a non-clustered SQL Server 2025: the DMV
181+
/// returns one row even with no failover cluster — empty name,
182+
/// NODE_MAJORITY (0), NORMAL_QUORUM (1). SSMS's Select-Top-1000
183+
/// server-properties batch reads it inside a TRY/CATCH that rethrows
184+
/// everything except permission errors, so zero rows or Msg 208
185+
/// escape to the user.
186+
/// </summary>
187+
[TestMethod]
188+
public void DmHadrCluster_SingleNonClusteredRow()
189+
=> AreEqual(1, new Simulation().ExecuteScalar("""
190+
select iif(cluster_name = N'' and quorum_type = 0 and quorum_type_desc = N'NODE_MAJORITY'
191+
and quorum_state = 1 and quorum_state_desc = N'NORMAL_QUORUM', 1, 0)
192+
from sys.dm_hadr_cluster
193+
"""));
194+
179195
[TestMethod]
180196
public void Views_ScriptAsColumns_ZeroForOrdinaryView()
181197
{

SqlServerSimulator.Tests/TryCatchTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,24 @@ public void NestedTry_InnerCatchOwnError_OuterDoesntSee()
163163
public void Throw_NoArgs_OutsideCatch_Msg10704()
164164
=> new Simulation().AssertSqlError("throw", 10704);
165165

166+
/// <summary>
167+
/// The Msg 10704 check is lexical — a bare THROW inside a CATCH whose
168+
/// TRY body succeeded (so the CATCH skip-parses) must not raise it.
169+
/// SSMS's Select-Top-1000 server-properties batch has this shape:
170+
/// the CATCH rethrows unless ERROR_NUMBER() is a tolerated permission
171+
/// error, and the TRY body normally succeeds.
172+
/// </summary>
173+
[TestMethod]
174+
public void Throw_NoArgs_InsideSkippedCatch_Parses()
175+
=> AreEqual(7, new Simulation().ExecuteScalar("""
176+
declare @x int;
177+
begin try set @x = 1 end try
178+
begin catch
179+
if (error_number() not in (297, 300)) begin throw end
180+
end catch
181+
select 7
182+
"""));
183+
166184
[TestMethod]
167185
public void Throw_NoArgs_InsideCatch_Rethrows()
168186
{

SqlServerSimulator/BuiltInResources.Security.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ private static IEnumerable<SqlValue[]> EnumerateSysDatabaseRoleMembers(Parser.Ba
319319
/// four 32-bit quadrants with a per-quadrant-salted FNV-1a hash so the same
320320
/// name always maps to the same bytes without persisting a GUID.
321321
/// </summary>
322-
private static byte[] DeriveLoginSid(string name)
322+
internal static byte[] DeriveLoginSid(string name)
323323
{
324324
var sid = new byte[16];
325325
for (var quadrant = 0; quadrant < 4; quadrant++)

SqlServerSimulator/BuiltInResources.ServerAndDatabases.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,22 @@ void Sys(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
380380
new("cluster_connection_options", SqlType.NVarchar, 4000, true),
381381
], static (_, _) => EmptyCatalogRows);
382382

383+
// sys.dm_hadr_cluster: single-row failover-clustering DMV. Probe-
384+
// confirmed against a non-clustered SQL Server 2025: even with no
385+
// cluster the view returns ONE row — empty cluster_name,
386+
// quorum_type 0 / NODE_MAJORITY, quorum_state 1 / NORMAL_QUORUM —
387+
// and SSMS's Select-Top-1000 server-properties batch reads it inside
388+
// a TRY/CATCH that tolerates only permission errors, so an empty
389+
// view (or Msg 208) escapes as a THROW.
390+
Sys("dm_hadr_cluster",
391+
[
392+
new("cluster_name", SqlType.NVarchar, 256, false),
393+
new("quorum_type", SqlType.TinyInt, null, false),
394+
new("quorum_type_desc", nvarchar60Catalog, 60, false),
395+
new("quorum_state", SqlType.TinyInt, null, false),
396+
new("quorum_state_desc", nvarchar60Catalog, 60, false),
397+
], static (_, _) => DmHadrClusterRows);
398+
383399
// sys.dm_hadr_database_replica_states: server-scope AlwaysOn DMV,
384400
// always empty (no AGs). SSMS's enumeration does
385401
// `insert into #tmp select group_database_id, synchronization_state,
@@ -613,6 +629,21 @@ void Metric(string metric, bool aggregatesNullable)
613629
/// </summary>
614630
private static readonly SqlValue[][] DmOsHostInfoRows = [BuildDmOsHostInfoRow()];
615631

632+
/// <summary>
633+
/// The single <c>sys.dm_hadr_cluster</c> row — a non-clustered
634+
/// instance's values, probe-confirmed against SQL Server 2025.
635+
/// </summary>
636+
private static readonly SqlValue[][] DmHadrClusterRows =
637+
[
638+
[
639+
SqlValue.FromNVarchar(string.Empty),
640+
SqlValue.FromByte(0),
641+
SqlValue.FromString(NVarcharSqlType.Get(60, Collation.Catalog, Coercibility.Implicit), "NODE_MAJORITY"),
642+
SqlValue.FromByte(1),
643+
SqlValue.FromString(NVarcharSqlType.Get(60, Collation.Catalog, Coercibility.Implicit), "NORMAL_QUORUM"),
644+
],
645+
];
646+
616647
private static SqlValue[] BuildDmOsHostInfoRow()
617648
{
618649
string platform, distribution, release;

SqlServerSimulator/Parser/Expression.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
750750
"REPLICATE" => new Replicate(context),
751751
"SCHEMA_ID" => new SchemaId(context),
752752
"SUBSTRING" => new Substring(context),
753+
"SUSER_SID" => new SUserSid(context),
753754
"TRANSLATE" => new Translate(context),
754755
"TRY_PARSE" => new ParseFunction(context, tryMode: true),
755756
"TYPE_NAME" => new TypeName(context),
@@ -775,6 +776,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
775776
"PWDCOMPARE" => new PwdCompare(context),
776777
"PWDENCRYPT" => new PwdEncrypt(context),
777778
"ROW_NUMBER" => WindowExpression.ParseRowNumber(context),
779+
"SID_BINARY" => new SidBinary(context),
778780
"STATS_DATE" => new StatsDate(context),
779781
"STRING_AGG" => AggregateExpression.Parse(context, AggregateKind.StringAgg),
780782
"SUSER_NAME" => new SUserName(context, isSidVariant: false),

SqlServerSimulator/Parser/Expressions/PrincipalScalarFunctions.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,92 @@ internal override string DebugDisplay() => this.arg is null
104104
: $"{(this.isSidVariant ? "SUSER_SNAME" : "SUSER_NAME")}({this.arg.DebugDisplay()})";
105105
}
106106

107+
/// <summary>
108+
/// SQL <c>SUSER_SID([login [, Param2]])</c>: returns the binary SID for a
109+
/// server login — the calling session's login with no argument. Mirrors the
110+
/// <c>sys.server_principals</c> sid surface: <c>sa</c> is the well-known
111+
/// single byte <c>0x01</c>, registry logins (<c>CREATE LOGIN</c>) get their
112+
/// deterministic 16-byte synthetic sid, and an unknown name returns NULL.
113+
/// The no-argument form returns <c>0x01</c>, matching the
114+
/// <c>sys.dm_exec_sessions.security_id</c> placeholder for the simulator's
115+
/// fixed session principal. The optional <c>Param2</c> (real's
116+
/// skip-name-validation flag) parses and is ignored. Result type is
117+
/// <c>varbinary(85)</c>.
118+
/// </summary>
119+
internal sealed class SUserSid : Expression
120+
{
121+
private readonly Expression? loginArg;
122+
123+
public SUserSid(ParserContext context)
124+
{
125+
if (context.Token is Tokens.Operator { Character: ')' })
126+
return;
127+
this.loginArg = Parse(context);
128+
if (context.Token is Tokens.Operator { Character: ',' })
129+
{
130+
context.MoveNextRequired();
131+
_ = Parse(context);
132+
}
133+
if (context.Token is not Tokens.Operator { Character: ')' })
134+
throw SimulatedSqlException.SyntaxErrorNear(context);
135+
}
136+
137+
public override SqlValue Run(RuntimeContext runtime)
138+
{
139+
if (this.loginArg is null)
140+
return SqlValue.FromVarbinary([0x01]);
141+
var nameValue = this.loginArg.Run(runtime);
142+
if (nameValue.IsNull)
143+
return SqlValue.Null(SqlType.Varbinary);
144+
var name = nameValue.CoerceTo(SqlType.SystemName).AsString;
145+
return Collation.Baseline.Equals(name, "sa")
146+
? SqlValue.FromVarbinary([0x01])
147+
: runtime.Batch.Connection.Simulation.Logins.ContainsKey(name)
148+
? SqlValue.FromVarbinary(BuiltInResources.DeriveLoginSid(name))
149+
: SqlValue.Null(SqlType.Varbinary);
150+
}
151+
152+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Varbinary;
153+
154+
internal override bool ResultIsNullable(Func<MultiPartName, bool> resolveColumnNullable) => true;
155+
156+
internal override string DebugDisplay() => this.loginArg is null ? "SUSER_SID()" : $"SUSER_SID({this.loginArg.DebugDisplay()})";
157+
}
158+
159+
/// <summary>
160+
/// SQL <c>SID_BINARY(name)</c>: resolves a Windows / Entra-ID principal
161+
/// name to its binary SID. Probe-confirmed against SQL Server 2025: it
162+
/// returns NULL even for existing SQL-auth logins (<c>sid_binary(N'sa')</c>
163+
/// is NULL) — it only resolves directory principals, which the simulator
164+
/// never hosts — so a constant NULL <c>varbinary(85)</c> is faithful for
165+
/// every input the simulator can see. The argument is still parsed and
166+
/// evaluated (one required argument). SSMS's Select-Top-1000
167+
/// server-properties batch calls it on the service's Windows group name.
168+
/// </summary>
169+
internal sealed class SidBinary : Expression
170+
{
171+
private readonly Expression arg;
172+
173+
public SidBinary(ParserContext context)
174+
{
175+
this.arg = Parse(context);
176+
if (context.Token is not Tokens.Operator { Character: ')' })
177+
throw SimulatedSqlException.SyntaxErrorNear(context);
178+
}
179+
180+
public override SqlValue Run(RuntimeContext runtime)
181+
{
182+
_ = this.arg.Run(runtime);
183+
return SqlValue.Null(SqlType.Varbinary);
184+
}
185+
186+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Varbinary;
187+
188+
internal override bool ResultIsNullable(Func<MultiPartName, bool> resolveColumnNullable) => true;
189+
190+
internal override string DebugDisplay() => $"SID_BINARY({this.arg.DebugDisplay()})";
191+
}
192+
107193
/// <summary>
108194
/// SQL <c>ORIGINAL_LOGIN()</c>: returns the original login of the session
109195
/// before any <c>EXECUTE AS</c> impersonation. The simulator doesn't model

SqlServerSimulator/Simulation/Simulation.TryCatch.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,16 +133,22 @@ private IEnumerable<SimulatedStatementOutcome> ParseTryCatch(BatchContext batch)
133133
{
134134
// No error caught — skip-dispatch CATCH body to advance the
135135
// cursor past END CATCH. Matches the "outer skipping" branch in
136-
// ParseWhileStatement.
136+
// ParseWhileStatement. CatchDepth still bumps: it tracks LEXICAL
137+
// containment, and the bare-THROW rethrow check (Msg 10704) is a
138+
// compile-time structural rule that must accept a THROW inside a
139+
// skipped CATCH body — SSMS's Select-Top-1000 server-properties
140+
// batch has exactly that shape once its TRY body succeeds.
137141
var wasSkipModeFlag = batch.SkipModeFlag;
138142
batch.SkipModeFlag = true;
143+
batch.CatchDepth++;
139144
try
140145
{
141146
foreach (var o in DispatchStatementsUntil(batch, endKeyword: Keyword.End))
142147
yield return o;
143148
}
144149
finally
145150
{
151+
batch.CatchDepth--;
146152
batch.SkipModeFlag = wasSkipModeFlag;
147153
}
148154
}

0 commit comments

Comments
 (0)