|
| 1 | +using System.Buffers.Binary; |
| 2 | +using System.Net; |
| 3 | +using System.Net.Sockets; |
| 4 | +using SqlServerSimulator.Network; |
| 5 | +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; |
| 6 | + |
| 7 | +namespace SqlServerSimulator; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// Frame-level regression guard pinning the server-to-client SMP (Session |
| 11 | +/// Multiplex Protocol) shape the real SQL Server 2025 emits — captured |
| 12 | +/// cleartext (Encrypt=False login-only encryption) via a tee proxy on |
| 13 | +/// 2026-07-18. The critical facts, and why they matter: Windows native SNI |
| 14 | +/// validates SMUX strictly and drops the physical connection ("Physical |
| 15 | +/// connection is not usable", SMux error 19) on shapes managed SNI (Linux) |
| 16 | +/// tolerates. These assertions fail on Linux if the multiplexer regresses, |
| 17 | +/// rather than only surfacing on a Windows host. |
| 18 | +/// <list type="bullet"> |
| 19 | +/// <item>The server sends NO SYN frame — a client SYN opens a session and the |
| 20 | +/// server's first frame on it is the DATA response.</item> |
| 21 | +/// <item>A complete (EOM) request gets NO standalone ACK; its DATA response |
| 22 | +/// piggybacks the advanced receive window (received + slack).</item> |
| 23 | +/// <item>A mid-message (EOM-clear) packet of a multi-packet request DOES get a |
| 24 | +/// standalone ACK advancing the window, since no response comes until the whole |
| 25 | +/// message arrives.</item> |
| 26 | +/// <item>Session close echoes a FIN whose SEQNUM is the last DATA sequence sent |
| 27 | +/// and whose WNDW is received + slack.</item> |
| 28 | +/// </list> |
| 29 | +/// The multiplexer is driven directly over a loopback socket pair through the |
| 30 | +/// <see cref="ISmpHost"/> seam with a canned session runner, so the test needs |
| 31 | +/// no TLS / login / engine. |
| 32 | +/// </summary> |
| 33 | +[TestClass] |
| 34 | +public sealed class SmpFrameTests |
| 35 | +{ |
| 36 | + public TestContext TestContext { get; set; } = null!; |
| 37 | + |
| 38 | + private const byte Syn = Tds.SmpFlagSyn; |
| 39 | + private const byte Ack = Tds.SmpFlagAck; |
| 40 | + private const byte Fin = Tds.SmpFlagFin; |
| 41 | + private const byte Data = Tds.SmpFlagData; |
| 42 | + |
| 43 | + private sealed record Frame(byte Flags, ushort Sid, uint Length, uint Seq, uint Window, byte[] Payload); |
| 44 | + |
| 45 | + /// <summary> |
| 46 | + /// Canned host: reads one whole TDS message per session then writes a single |
| 47 | + /// tabular-result packet as the response, mirroring the real session loop's |
| 48 | + /// FIN on completion. No engine involved. |
| 49 | + /// </summary> |
| 50 | + private sealed class CannedHost : ISmpHost |
| 51 | + { |
| 52 | + public SmpMultiplexer Multiplexer = null!; |
| 53 | + |
| 54 | + public async Task RunMarsSessionAsync(SmpSession session, CancellationToken cancellationToken) |
| 55 | + { |
| 56 | + using var stream = new SmpSessionStream(session); |
| 57 | + var transport = new TdsPacketTransport(stream) { PacketSize = Tds.DefaultPacketSize }; |
| 58 | + var message = await transport.ReadMessageAsync(cancellationToken).ConfigureAwait(false); |
| 59 | + if (message is not null) |
| 60 | + await transport.WritePacketAsync(Tds.PacketTabularResult, new byte[] { Tds.TokenDone, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, endOfMessage: true, cancellationToken).ConfigureAwait(false); |
| 61 | + await this.Multiplexer.SendFinAsync(session, CancellationToken.None).ConfigureAwait(false); |
| 62 | + } |
| 63 | + |
| 64 | + public void CancelConnectionExecution() |
| 65 | + { |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + private static byte[] BuildFrame(byte flags, ushort sid, uint seq, uint window, byte[]? payload = null) |
| 70 | + { |
| 71 | + payload ??= []; |
| 72 | + var frame = new byte[Tds.SmpHeaderSize + payload.Length]; |
| 73 | + frame[0] = Tds.SmpSmid; |
| 74 | + frame[1] = flags; |
| 75 | + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(2), sid); |
| 76 | + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(4), (uint)frame.Length); |
| 77 | + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(8), seq); |
| 78 | + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(12), window); |
| 79 | + payload.CopyTo(frame.AsSpan(Tds.SmpHeaderSize)); |
| 80 | + return frame; |
| 81 | + } |
| 82 | + |
| 83 | + /// <summary>A minimal single-packet SQLBatch TDS packet (header + trivial body).</summary> |
| 84 | + private static byte[] TdsBatchPacket(bool endOfMessage) |
| 85 | + { |
| 86 | + var body = new byte[] { 0, 0, 0, 0, 1, 0 }; // ALL_HEADERS-free trivial payload; content is irrelevant to framing. |
| 87 | + var packet = new byte[Tds.HeaderSize + body.Length]; |
| 88 | + packet[0] = Tds.PacketSqlBatch; |
| 89 | + packet[1] = endOfMessage ? Tds.StatusEndOfMessage : (byte)0; |
| 90 | + packet[2] = (byte)((Tds.HeaderSize + body.Length) >> 8); |
| 91 | + packet[3] = (byte)(Tds.HeaderSize + body.Length); |
| 92 | + body.CopyTo(packet.AsSpan(Tds.HeaderSize)); |
| 93 | + return packet; |
| 94 | + } |
| 95 | + |
| 96 | + private static async Task<List<Frame>> RunAsync(CancellationToken cancellationToken, params byte[][] clientFrames) |
| 97 | + { |
| 98 | + using var listener = new TcpListener(IPAddress.Loopback, 0); |
| 99 | + listener.Start(); |
| 100 | + using var clientSocket = new TcpClient(); |
| 101 | + var acceptTask = listener.AcceptTcpClientAsync(cancellationToken).AsTask(); |
| 102 | + await clientSocket.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port, cancellationToken); |
| 103 | + using var serverTcp = await acceptTask; |
| 104 | + await using var serverStream = serverTcp.GetStream(); |
| 105 | + await using var clientStream = clientSocket.GetStream(); |
| 106 | + |
| 107 | + var host = new CannedHost(); |
| 108 | + using var mux = new SmpMultiplexer(serverStream, host); |
| 109 | + host.Multiplexer = mux; |
| 110 | + var muxTask = mux.RunAsync(cancellationToken); |
| 111 | + |
| 112 | + foreach (var frame in clientFrames) |
| 113 | + { |
| 114 | + await clientStream.WriteAsync(frame, cancellationToken); |
| 115 | + await clientStream.FlushAsync(cancellationToken); |
| 116 | + } |
| 117 | + |
| 118 | + var frames = new List<Frame>(); |
| 119 | + var header = new byte[Tds.SmpHeaderSize]; |
| 120 | + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
| 121 | + readCts.CancelAfter(TimeSpan.FromSeconds(5)); |
| 122 | + try |
| 123 | + { |
| 124 | + while (true) |
| 125 | + { |
| 126 | + await clientStream.ReadExactlyAsync(header, readCts.Token); |
| 127 | + var length = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(4)); |
| 128 | + var payload = new byte[length - Tds.SmpHeaderSize]; |
| 129 | + if (payload.Length > 0) |
| 130 | + await clientStream.ReadExactlyAsync(payload, readCts.Token); |
| 131 | + frames.Add(new Frame( |
| 132 | + header[1], |
| 133 | + BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(2)), |
| 134 | + length, |
| 135 | + BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(8)), |
| 136 | + BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(12)), |
| 137 | + payload)); |
| 138 | + |
| 139 | + // Stop once every opened session has been FIN-ed by the server. |
| 140 | + if ((header[1] & Fin) != 0) |
| 141 | + break; |
| 142 | + } |
| 143 | + } |
| 144 | + catch (OperationCanceledException) |
| 145 | + { |
| 146 | + } |
| 147 | + catch (EndOfStreamException) |
| 148 | + { |
| 149 | + } |
| 150 | + |
| 151 | + return frames; |
| 152 | + } |
| 153 | + |
| 154 | + [TestMethod] |
| 155 | + public async Task CompleteRequest_ServerEmitsNoSyn_DataThenFin() |
| 156 | + { |
| 157 | + var frames = await RunAsync( |
| 158 | + TestContext.CancellationToken, |
| 159 | + BuildFrame(Syn, sid: 1, seq: 0, window: 4), |
| 160 | + BuildFrame(Data, sid: 1, seq: 1, window: 4, TdsBatchPacket(endOfMessage: true))); |
| 161 | + |
| 162 | + var synCount = frames.Count(f => (f.Flags & Syn) != 0); |
| 163 | + var ackCount = frames.Count(f => (f.Flags & Ack) != 0); |
| 164 | + AreEqual(0, synCount, "Server must never emit a SYN frame (native SNI drops the connection on one)."); |
| 165 | + AreEqual(0, ackCount, "A complete request needs no standalone ACK — the DATA response piggybacks the window."); |
| 166 | + |
| 167 | + var data = frames.Single(f => (f.Flags & Data) != 0); |
| 168 | + AreEqual((ushort)1, data.Sid); |
| 169 | + AreEqual(1u, data.Seq); |
| 170 | + AreEqual(5u, data.Window); // received 1 + slack 4 |
| 171 | + |
| 172 | + var fin = frames.Single(f => (f.Flags & Fin) != 0); |
| 173 | + AreEqual((ushort)1, fin.Sid); |
| 174 | + AreEqual(1u, fin.Seq); // SEQNUM = last DATA sequence sent |
| 175 | + AreEqual(5u, fin.Window); |
| 176 | + } |
| 177 | + |
| 178 | + [TestMethod] |
| 179 | + public async Task MultiPacketRequest_MidMessagePacketGetsAck() |
| 180 | + { |
| 181 | + var frames = await RunAsync( |
| 182 | + TestContext.CancellationToken, |
| 183 | + BuildFrame(Syn, sid: 1, seq: 0, window: 4), |
| 184 | + BuildFrame(Data, sid: 1, seq: 1, window: 4, TdsBatchPacket(endOfMessage: false)), |
| 185 | + BuildFrame(Data, sid: 1, seq: 2, window: 5, TdsBatchPacket(endOfMessage: true))); |
| 186 | + |
| 187 | + var synCount = frames.Count(f => (f.Flags & Syn) != 0); |
| 188 | + AreEqual(0, synCount, "Server must never emit a SYN frame."); |
| 189 | + |
| 190 | + // The mid-message (EOM-clear) first packet is ACKed to advance the send |
| 191 | + // window; the completing second packet is not (its response piggybacks). |
| 192 | + var ack = frames.Single(f => f.Flags == Ack); |
| 193 | + AreEqual((ushort)1, ack.Sid); |
| 194 | + AreEqual(5u, ack.Window); // received 1 + slack 4 |
| 195 | + |
| 196 | + var dataCount = frames.Count(f => (f.Flags & Data) != 0); |
| 197 | + var finCount = frames.Count(f => (f.Flags & Fin) != 0); |
| 198 | + IsGreaterThan(0, dataCount, "The completed request must produce a DATA response."); |
| 199 | + IsGreaterThan(0, finCount, "The session must be FIN-ed on completion."); |
| 200 | + } |
| 201 | +} |
0 commit comments