Skip to content

Commit d495b9e

Browse files
committed
SimulatedNetworkListenerOptions can now be supplied to ListenLocalAsync/ListenNetworkAsync to carry parameters, including a newly introduced way to carry a user-supplied certificate for encrypted connections and an IP address for binding.
1 parent 23fdc83 commit d495b9e

11 files changed

Lines changed: 432 additions & 71 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Field rosters live in the source XML docs; this captures only identity + load-be
123123

124124
- **`Simulation`** = server / instance.
125125
Holds `SystemHeapTables`, the `Databases` dict, and `ServerCollationName` (`init`-only; defaults `SQL_Latin1_General_CP1_CI_AS`; mirrors `model.collation` — install-time seed for every new `Database`, both the lazy `"simulated"` seed and collation-less bacpac imports; `init` reflects real immutability).
126-
Public surface = `Simulation` ctor + `CreateDbConnection()` + `ImportBacpac()` + `AddRemoteSimulation()` + `ServerCollationName` + `ListenLocalAsync()` / `ListenNetworkAsync()``SimulatedNetworkListener`.
126+
Public surface = `Simulation` ctor + `CreateDbConnection()` + `ImportBacpac()` + `AddRemoteSimulation()` + `ServerCollationName` + `ListenLocalAsync()` / `ListenNetworkAsync()` (int-port or `SimulatedNetworkListenerOptions` overloads) `SimulatedNetworkListener`.
127127
- **`Database`** (internal) = one database.
128128
Holds `Schemas`, `CompatibilityLevel`, `CollationName`, the rowversion counter (`@@DBTS`), the MVCC version store, and the principal/permission/extended-property/full-text/DDL-trigger surfaces.
129129
`Databases` is seeded at construction with all four system databases — `master` / `tempdb` / `model` / `msdb` (so `USE <systemdb>` / `master.sys.*` / `master.dbo.<proc>` / SSMS's `msdb.dbo.syspolicy_system_health_state` all resolve without an import); the first `CreateDbConnection()` lazily seeds `DefaultDatabaseName` (`"simulated"`) when no *user* database is present, and all four system databases are excluded from the initial-database fallback (`Simulation.SystemDatabaseNames`) so a fresh connection still lands on `simulated`.
@@ -270,7 +270,7 @@ Each entry below is a trigger: read the linked file on demand when working in th
270270
- **New top-level statement parser or dispatch-loop separator rules, double-quoted identifiers / QUOTED_IDENTIFIER**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
271271
- **BACPAC import** (`Simulation.ImportBacpac` — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
272272
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server,'query')` ad-hoc pass-through + compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
273-
- **TDS network endpoint** (`Simulation.ListenLocalAsync` (loopback) / `Simulation.ListenNetworkAsync` (all interfaces, requires a registered login) → `SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM/BulkLoad (`SqlBulkCopy`); legacy `text`/`ntext`/`image` result columns + `image` RPC input params in the in-band textptr wire form; TVP (`0xF3` `SqlDbType.Structured`) RPC params via the shared `TdsColumnDecoder`; `sp_cursor*` API-server-cursor RPC family; mid-stream attention (cancel / `CommandTimeout`) via a carry-forward concurrent read → `DONE_ATTN` ack + probed XACT_ABORT tx semantics; MARS (`MultipleActiveResultSets=True`) via an SMP demux/mux over one shared connection with serialized cooperative multiplexing + per-session attention; TDS 8.0 `Encrypt=Strict` (TLS-first, ALPN `tds/8.0`, client pins the exposed `SimulatedNetworkListener.ServerCertificate` since SqlClient ignores `TrustServerCertificate` in strict mode); credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
273+
- **TDS network endpoint** (`Simulation.ListenLocalAsync` (loopback) / `Simulation.ListenNetworkAsync` (all interfaces, requires a registered login) → `SimulatedNetworkListener`, both with `SimulatedNetworkListenerOptions` overloads (port + caller-owned TLS certificate + network-only single-interface `BindAddress`); real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM/BulkLoad (`SqlBulkCopy`); legacy `text`/`ntext`/`image` result columns + `image` RPC input params in the in-band textptr wire form; TVP (`0xF3` `SqlDbType.Structured`) RPC params via the shared `TdsColumnDecoder`; `sp_cursor*` API-server-cursor RPC family; mid-stream attention (cancel / `CommandTimeout`) via a carry-forward concurrent read → `DONE_ATTN` ack + probed XACT_ABORT tx semantics; MARS (`MultipleActiveResultSets=True`) via an SMP demux/mux over one shared connection with serialized cooperative multiplexing + per-session attention; TDS 8.0 `Encrypt=Strict` (TLS-first, ALPN `tds/8.0`, client pins the exposed `SimulatedNetworkListener.ServerCertificate` since SqlClient ignores `TrustServerCertificate` in strict mode); credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
274274

275275
## Not modeled
276276

docs/claude/backlog.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ Remaining phases, roughly in value order:
3737
Candidate follow-on legs within tool scope: Visual Studio's SQL Server Object Explorer (DacFx-driven, a different query dialect from SMO) and LINQPad.
3838
- **SMO API sweep campaign**`.vs/smo-sweep` (gitignored local harness) walks SMO's full reachable read surface against the self-hosted simulator and, identically, against the live reference, draining every `Property.Value` and `Script()`-ing every `IScriptable`; modes `sweep` / `sweep --live` / `diff` → sorted JSON reports + `reports/triage.md`; workflow = sweep both sides → triage → fix bundles → graduated `Tests.Smo` tests → re-sweep.
3939
Open items from the latest triage: (a) `DBCC SHOW_STATISTICS … WITH STATS_STREAM` (SMO `Statistic.Stream`) stays `NotSupportedException` — it wants the raw serialized statistics-histogram blob, which the simulator has no faithful source for; (b) the unmodeled runtime/OS surfaces SMO reaches as absent objects (backup history `msdb.dbo.backupset`, `sys.dm_tran_persistent_version_store_stats`, file-space/IO DMVs, `sys.dm_os_process_memory`, `master.dbo.sysprocesses`, `FILEPROPERTY`, registry/OS xps) — surfaced as `PropertyCannotBeRetrievedException` / defaults, the legitimate-gap category.
40-
- **Smaller fidelity items**: user-supplied `X509Certificate2` (a CA-trusted certificate instead of the ephemeral self-signed one; strict-mode clients can already pin the exposed `SimulatedNetworkListener.ServerCertificate`) and per-interface bind-address selection — add-on-demand public-surface expansions.
41-
Open residuals of shipped wire features (details in [`tds-endpoint.md`](tds-endpoint.md)): cancel/attention reaction is bounded by the in-flight statement's materialization, not interruptible inside a single statement's row loop; MARS never raises Msg 8628/8651 and fully materializes each session's response under the execution gate.
40+
- **Open residuals of shipped wire features** (details in [`tds-endpoint.md`](tds-endpoint.md)): cancel/attention reaction is bounded by the in-flight statement's materialization, not interruptible inside a single statement's row loop; MARS never raises Msg 8628/8651 and fully materializes each session's response under the execution gate.
4241
- **Chunked `OFFSET/FETCH` paging: per-page constant factor, complexity class matches real** (probed 2026-07-18, plans + timings): real SQL Server also redoes the work on every page — `Top(OFFSET…)` over an ordered index scan reading offset+fetch rows when an index supplies the order, or a full scan + `Sort(TOP offset+fetch)` re-sorted per page when not; no cross-query sorted-result caching exists on either side, so "sort once, serve many pages" is rejected (it would invent behavior real doesn't have).
4342
The residual is constant-factor only: the simulator materializes (and for non-index order, sorts) all n rows per page regardless of offset where real's indexed plan touches only offset+fetch (measured at 150k rows / fetch 100 / offset 140k: sim ~41 ms vs real ~13 ms indexed, ~116 ms vs ~22 ms unindexed — real's sort is also 16-way parallel).
4443
Possible lever if paged drains ever matter: recognize index-supplied order in the `OFFSET/FETCH` path (real's plan shape) to skip the sort and bound the scan.

docs/claude/tds-endpoint.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44
Returns `Task<SimulatedNetworkListener>`; `port: 0` binds an OS-assigned ephemeral port (read `Port`), the right shape for parallel tests.
55
Port-in-use surfaces as the raw `SocketException`.
66
The listener binds IPv4 loopback plus best-effort IPv6 loopback on the same port.
7+
Both listen methods also take a **`SimulatedNetworkListenerOptions`** overload (`Port`, default 1433 matching the int overloads; `BindAddress`; `ServerCertificate`, default null = generate ephemeral): a supplied certificate must carry a private key (`ArgumentException` otherwise) and **stays owned by the caller — the listener never disposes it** — so one certificate serves many listeners (created once at suite setup, its public part exported once for strict-mode pinning instead of per-listener temp files).
8+
`BindAddress` narrows `ListenNetworkAsync` from all interfaces to exactly one address — its family decides the socket family and **no best-effort other-family sibling is bound** — while `ListenLocalAsync` rejects a non-null value with `ArgumentException` (honoring it would put the loopback method's accept-anyone-until-`CREATE LOGIN` credential model on a network interface).
79
**`ListenNetworkAsync`** is the all-interfaces sibling (`IPAddress.Any` + best-effort `IPv6Any`, same core): it throws `InvalidOperationException` at call time when no logins are registered — the loopback listener's accept-anyone-until-`CREATE LOGIN` model must never face a network — and its XML docs carry the honest caveat that authentication is the *only* enforcement (no authorization model; every login has unrestricted access; the self-signed cert doesn't authenticate the server).
810
Oracle: `NetworkListenerTests`.
911

10-
Connection-string requirements: `TrustServerCertificate=true` (the endpoint presents an ephemeral in-memory self-signed cert generated per listener) and credentials per the enforcement rule below (any credentials when no logins exist).
12+
Connection-string requirements: `TrustServerCertificate=true` (the endpoint presents an ephemeral in-memory self-signed cert generated per listener, unless the options overload supplied one — a CA-trusted supplied certificate makes the flag unnecessary) and credentials per the enforcement rule below (any credentials when no logins exist).
1113
Default/`Mandatory`/`Optional` all negotiate to full encryption via `ENCRYPT_REQ`.
1214
A client that cannot do TLS at all (`ENCRYPT_NOT_SUP`) is disconnected after the prelogin response — there is no plaintext mode.
1315

1416
**`Encrypt=Strict` (TDS 8.0) ships**: the client opens with a bare TLS ClientHello negotiating ALPN `tds/8.0`, and every TDS packet — prelogin included — flows inside the TLS channel; the session routes on the first wire byte (TLS handshake record `0x16` vs cleartext PRELOGIN `0x12`).
1517
SqlClient **ignores `TrustServerCertificate` in strict mode** and always validates the certificate (chain + hostname), so a strict client must pin instead: export `SimulatedNetworkListener.ServerCertificate` (the presented certificate, public part only) to a file and reference it with the connection string's `ServerCertificate` keyword (empirically confirmed against SqlClient 7.0.2 — `TrustServerCertificate=True` alone fails strict with `UntrustedRoot` + hostname mismatch).
18+
Supplying one certificate through `SimulatedNetworkListenerOptions` makes the pin file a create-once artifact shared by every listener (the shape `StrictEncryptionTests` uses: one class-level certificate, its public part written to a fixed-name file in the OS temp directory).
1619
The strict TLS handshake is not version-pinned (TLS 1.3 negotiates; raw records make NewSessionTicket harmless), and LOGINACK echoes TDS version `0x08000000` when LOGIN7 requested it.
1720
MARS and `SqlBulkCopy` ride the strict channel unchanged.
1821
Oracle: `StrictEncryptionTests`.

src/SqlServerSimulator/Network/TdsServerCertificate.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,19 @@ namespace SqlServerSimulator.Network;
1010
/// </summary>
1111
internal static class TdsServerCertificate
1212
{
13-
public static X509Certificate2 Create()
13+
public static X509Certificate2 Create() =>
14+
X509CertificateLoader.LoadPkcs12(CreatePkcs12(), password: null);
15+
16+
/// <summary>
17+
/// PKCS#12 bytes (no password) for a fresh certificate. Exposed
18+
/// separately from <see cref="Create"/> because the bytes must be
19+
/// captured before <see cref="X509CertificateLoader"/> touches
20+
/// a platform key store: Windows marks a loaded private key
21+
/// non-exportable, so a store-loaded certificate cannot be re-exported
22+
/// as PKCS#12 ("Key not valid for use in specified state") — callers
23+
/// persisting the certificate write these bytes instead.
24+
/// </summary>
25+
public static byte[] CreatePkcs12()
1426
{
1527
using var rsa = RSA.Create(2048);
1628
var request = new CertificateRequest("CN=SqlServerSimulator", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
@@ -19,6 +31,6 @@ public static X509Certificate2 Create()
1931
// SslStream requires a certificate whose private key is loadable by
2032
// the platform TLS stack; round-tripping through PKCS#12 guarantees
2133
// that on every OS, where the CreateSelfSigned result alone does not.
22-
return X509CertificateLoader.LoadPkcs12(selfSigned.Export(X509ContentType.Pkcs12), password: null);
34+
return selfSigned.Export(X509ContentType.Pkcs12);
2335
}
2436
}

src/SqlServerSimulator/SimulatedNetworkListener.cs

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ namespace SqlServerSimulator;
88
/// <summary>
99
/// A TCP endpoint speaking the SQL Server wire protocol (TDS), letting
1010
/// unmodified SQL Server clients connect to a simulation with only a
11-
/// connection-string change. Created by the listen method on the simulation
12-
/// type; accepts loopback connections only. The endpoint presents an
13-
/// ephemeral self-signed TLS certificate, so clients must connect with
14-
/// <c>TrustServerCertificate=true</c>. Credentials are accepted without
15-
/// validation.
11+
/// connection-string change. Created by the listen methods on the simulation
12+
/// type; accepts loopback connections only. The endpoint presents the TLS
13+
/// certificate supplied through the listen options, or an ephemeral
14+
/// self-signed one when none was supplied — the latter requires clients to
15+
/// connect with <c>TrustServerCertificate=true</c>. Credentials are accepted
16+
/// without validation.
1617
/// </summary>
1718
/// <remarks>
1819
/// Disposal is immediate and waits for nothing: the listening sockets close,
@@ -39,24 +40,33 @@ public sealed class SimulatedNetworkListener : IDisposable, IAsyncDisposable
3940
/// </summary>
4041
public X509Certificate2 ServerCertificate { get; }
4142

42-
private readonly Socket listenerV4;
43-
private readonly Socket? listenerV6;
43+
private readonly Socket primaryListener;
44+
private readonly Socket? secondaryListener;
4445
private readonly X509Certificate2 certificate;
46+
47+
/// <summary>
48+
/// True when the listener generated <see cref="certificate"/> itself and
49+
/// must dispose it; false when the certificate was supplied through the
50+
/// listen options, whose caller retains ownership.
51+
/// </summary>
52+
private readonly bool ownsCertificate;
53+
4554
private readonly CancellationTokenSource stopSource = new();
4655
private readonly ConcurrentDictionary<TdsSession, byte> sessions = new();
4756
private int disposed;
4857

49-
internal SimulatedNetworkListener(Simulation simulation, Socket listenerV4, Socket? listenerV6, X509Certificate2 certificate, int port)
58+
internal SimulatedNetworkListener(Simulation simulation, Socket primaryListener, Socket? secondaryListener, X509Certificate2 certificate, bool ownsCertificate, int port)
5059
{
51-
this.listenerV4 = listenerV4;
52-
this.listenerV6 = listenerV6;
60+
this.primaryListener = primaryListener;
61+
this.secondaryListener = secondaryListener;
5362
this.certificate = certificate;
63+
this.ownsCertificate = ownsCertificate;
5464
this.ServerCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
5565
this.Port = port;
5666

57-
_ = this.AcceptLoopAsync(simulation, listenerV4);
58-
if (listenerV6 is not null)
59-
_ = this.AcceptLoopAsync(simulation, listenerV6);
67+
_ = this.AcceptLoopAsync(simulation, primaryListener);
68+
if (secondaryListener is not null)
69+
_ = this.AcceptLoopAsync(simulation, secondaryListener);
6070
}
6171

6272
/// <summary>
@@ -69,12 +79,13 @@ public void Dispose()
6979
return;
7080

7181
this.stopSource.Cancel();
72-
this.listenerV4.Dispose();
73-
this.listenerV6?.Dispose();
82+
this.primaryListener.Dispose();
83+
this.secondaryListener?.Dispose();
7484
foreach (var session in this.sessions.Keys)
7585
session.Abort();
7686

77-
this.certificate.Dispose();
87+
if (this.ownsCertificate)
88+
this.certificate.Dispose();
7889
this.ServerCertificate.Dispose();
7990
this.stopSource.Dispose();
8091
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Net;
2+
using System.Security.Cryptography.X509Certificates;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Configuration for the listen methods on <see cref="Simulation"/>, accepted
8+
/// by the options overloads of
9+
/// <see cref="Simulation.ListenLocalAsync(SimulatedNetworkListenerOptions, CancellationToken)"/>
10+
/// and
11+
/// <see cref="Simulation.ListenNetworkAsync(SimulatedNetworkListenerOptions, CancellationToken)"/>.
12+
/// </summary>
13+
public sealed class SimulatedNetworkListenerOptions
14+
{
15+
/// <summary>
16+
/// The TCP port to bind, 1433 by default. Pass 0 to let the operating
17+
/// system assign an ephemeral port, reported by the listener's port
18+
/// property — the right choice for parallel test runs.
19+
/// </summary>
20+
public int Port { get; init; } = 1433;
21+
22+
/// <summary>
23+
/// A single interface address for
24+
/// <see cref="Simulation.ListenNetworkAsync(SimulatedNetworkListenerOptions, CancellationToken)"/>
25+
/// to bind instead of all interfaces — exactly this address, with no
26+
/// best-effort second-family sibling. Null (the default) keeps each
27+
/// method's standard binding: the loopback pair for the local method,
28+
/// the all-interfaces pair for the network method. Only the network
29+
/// method honors it;
30+
/// <see cref="Simulation.ListenLocalAsync(SimulatedNetworkListenerOptions, CancellationToken)"/>
31+
/// raises <see cref="ArgumentException"/> for a non-null value, because
32+
/// loopback-only is its contract — its accept-anyone-until-a-login-exists
33+
/// credential model must never face a network interface.
34+
/// </summary>
35+
public IPAddress? BindAddress { get; init; }
36+
37+
/// <summary>
38+
/// The TLS certificate the listener presents during the handshake, which
39+
/// must include a private key (a certificate without one raises
40+
/// <see cref="ArgumentException"/>). The caller retains ownership — the
41+
/// listener never disposes a supplied certificate — so one certificate
42+
/// can serve many listeners: created once at suite setup and, for
43+
/// <c>Encrypt=Strict</c> clients, exported once to a file that connection
44+
/// strings pin via the <c>ServerCertificate</c> keyword. Null (the
45+
/// default) generates an ephemeral self-signed certificate that lives and
46+
/// dies with the listener.
47+
/// </summary>
48+
public X509Certificate2? ServerCertificate { get; init; }
49+
}

0 commit comments

Comments
 (0)