From d2dce2fe0ed75b5e938d0f384b55ba6efbb6c553 Mon Sep 17 00:00:00 2001 From: Joep Joosten Date: Tue, 8 Sep 2026 14:39:44 +0200 Subject: [PATCH 1/4] Add native TDS driver for sql-mssql with Docker tests and benchmarks --- .changeset/native-mssql-tds.md | 11 + packages/sql/mssql/README.md | 36 +- packages/sql/mssql/benchmark/README.md | 81 +++ packages/sql/mssql/benchmark/TdsClient.ts | 176 +++++ packages/sql/mssql/benchmark/TdsCodec.ts | 92 +++ packages/sql/mssql/benchmark/results.jsonl | 6 + packages/sql/mssql/package.json | 9 +- packages/sql/mssql/src/MssqlClient.ts | 377 ++++------- packages/sql/mssql/src/MssqlTypes.ts | 283 ++++++++ packages/sql/mssql/src/Parameter.ts | 9 +- packages/sql/mssql/src/Procedure.ts | 5 +- packages/sql/mssql/src/index.ts | 12 +- packages/sql/mssql/src/internal/js-md4.d.ts | 6 + .../sql/mssql/src/internal/tdsCollation.ts | 331 +++++++++ .../sql/mssql/src/internal/tdsConnection.ts | 629 ++++++++++++++++++ packages/sql/mssql/src/internal/tdsNtlm.ts | 148 +++++ packages/sql/mssql/src/internal/tdsPacket.ts | 250 +++++++ packages/sql/mssql/src/internal/tdsRequest.ts | 442 ++++++++++++ packages/sql/mssql/src/internal/tdsToken.ts | 464 +++++++++++++ packages/sql/mssql/test/Binary.test.ts | 34 +- packages/sql/mssql/test/Client.test.ts | 74 +-- .../mssql/test/SqlErrorClassification.test.ts | 94 +-- .../test/TdsConnection.in-process.test.ts | 252 +++++++ .../test/TdsConnection.integration.test.ts | 205 ++++++ packages/sql/mssql/test/TdsNtlm.test.ts | 63 ++ packages/sql/mssql/test/TdsPacket.test.ts | 84 +++ packages/sql/mssql/test/TdsRequest.test.ts | 48 ++ packages/sql/mssql/test/TdsToken.test.ts | 71 ++ packages/sql/mssql/test/utils.ts | 42 +- pnpm-lock.yaml | 12 +- 30 files changed, 3894 insertions(+), 452 deletions(-) create mode 100644 .changeset/native-mssql-tds.md create mode 100644 packages/sql/mssql/benchmark/README.md create mode 100644 packages/sql/mssql/benchmark/TdsClient.ts create mode 100644 packages/sql/mssql/benchmark/TdsCodec.ts create mode 100644 packages/sql/mssql/benchmark/results.jsonl create mode 100644 packages/sql/mssql/src/MssqlTypes.ts create mode 100644 packages/sql/mssql/src/internal/js-md4.d.ts create mode 100644 packages/sql/mssql/src/internal/tdsCollation.ts create mode 100644 packages/sql/mssql/src/internal/tdsConnection.ts create mode 100644 packages/sql/mssql/src/internal/tdsNtlm.ts create mode 100644 packages/sql/mssql/src/internal/tdsPacket.ts create mode 100644 packages/sql/mssql/src/internal/tdsRequest.ts create mode 100644 packages/sql/mssql/src/internal/tdsToken.ts create mode 100644 packages/sql/mssql/test/TdsConnection.in-process.test.ts create mode 100644 packages/sql/mssql/test/TdsConnection.integration.test.ts create mode 100644 packages/sql/mssql/test/TdsNtlm.test.ts create mode 100644 packages/sql/mssql/test/TdsPacket.test.ts create mode 100644 packages/sql/mssql/test/TdsRequest.test.ts create mode 100644 packages/sql/mssql/test/TdsToken.test.ts diff --git a/.changeset/native-mssql-tds.md b/.changeset/native-mssql-tds.md new file mode 100644 index 00000000000..e874b16587c --- /dev/null +++ b/.changeset/native-mssql-tds.md @@ -0,0 +1,11 @@ +--- +"@effect/sql-mssql": minor +--- + +Replace the tedious runtime dependency with an Effect-native TDS 7.4 driver, +including TLS, SQL and NTLMv2 authentication, parameter and result codecs, +stored procedures, table-valued parameters, transaction-aware calls and safe +cancellation. Export native MssqlTypes descriptors and add requestTimeout. + +Azure/Entra authentication is not yet supported. Native parameter descriptors +replace tedious descriptors; see the package README for compatibility limits. diff --git a/packages/sql/mssql/README.md b/packages/sql/mssql/README.md index c2933978b71..e374cf36525 100644 --- a/packages/sql/mssql/README.md +++ b/packages/sql/mssql/README.md @@ -1,6 +1,8 @@ # @effect/sql-mssql -An Effect SQL client for Microsoft SQL Server, built on the [`tedious`](https://tediousjs.github.io/tedious/) library. +An Effect SQL client for Microsoft SQL Server with a native TDS 7.4 implementation. +Connections, pooling, transactions and cancellation are managed through Effect; +the runtime does not depend on `tedious`. ## Installation @@ -12,3 +14,35 @@ npm install effect@rc @effect/sql-mssql@rc - [Effect website](https://effect.website) - [API reference](https://effect.website/docs/v4/api/sql-mssql) + +## Native driver + +The driver supports encrypted SQL authentication, NTLMv2, named-instance discovery, +server-directed routing, parameterized queries, stored procedures with output +parameters, table-valued parameters, and nested transactions using savepoints. +Interrupted or timed-out requests send ATTENTION and drain its acknowledgement +before the connection can be reused. Failed connections are removed from the pool. + +TLS is enabled by default and certificates are verified. `trustServer: true` is +intended for explicitly trusted self-signed servers, such as local test containers. +`encrypt: false` disables transport protection, including protection of credentials. +The request timeout defaults to 15 seconds; `requestTimeout: 0` disables it. + +Use `MssqlTypes` from `@effect/sql-mssql` for procedure parameter descriptors and +`parameterTypes`. These are native descriptors, not the objects exported by tedious. +SQL `bigint` results remain strings; decimal and numeric results remain JavaScript +numbers, which can lose precision. Exact decimal input can be supplied as a string. + +Compatibility limits of this implementation: + +- Azure/Entra authentication is not implemented; unsupported `authType` values fail explicitly. +- NTLMv2 has protocol/vector tests, but has not been verified against a live Windows + domain. Extended Protection/channel binding is not implemented. +- Streaming queries remain unsupported. Results are buffered, with a 16 MiB per-token + safety limit; a single larger row or value is rejected and closes the connection. +- Table-valued parameters use arrays of rows. Async row producers are not supported. +- UDT and SQL_VARIANT result values are decoded, but these types cannot be used + as input or output parameter descriptors. UDT results are returned as binary data. + +See [the benchmark guide](./benchmark/README.md) for live Docker tests and comparisons +with tedious. Tedious is retained only as a development benchmark dependency. diff --git a/packages/sql/mssql/benchmark/README.md b/packages/sql/mssql/benchmark/README.md new file mode 100644 index 00000000000..3f44c67915b --- /dev/null +++ b/packages/sql/mssql/benchmark/README.md @@ -0,0 +1,81 @@ +# Native TDS verification and benchmarks + +Run from the repository root after `pnpm install`. The benchmark-only tedious +dependency does not appear in the runtime dependency graph. + +## Live SQL Server + +Use a dedicated SQL Server 2022 Docker container. On ARM hosts the official image +runs under amd64 emulation. This example exposes SQL Server only on localhost and +accepts Microsoft's container EULA. The password below is for disposable local +tests, not deployments. + +```sh +docker run --detach --name effect-native-mssql-test --platform linux/amd64 \ + -e ACCEPT_EULA=Y -e 'MSSQL_SA_PASSWORD=Effect_Tds_Test_7426!' \ + -p 127.0.0.1:14339:1433 mcr.microsoft.com/mssql/server:2022-latest +docker logs effect-native-mssql-test +``` + +After the server reports that it is ready for client connections: + +```sh +EFFECT_INTEGRATION_TESTS=1 MSSQL_PORT=14339 pnpm test --run packages/sql/mssql/test +node packages/sql/mssql/benchmark/TdsClient.ts +node packages/sql/mssql/benchmark/TdsCodec.ts +FRAGMENT_BYTES=4096 node packages/sql/mssql/benchmark/TdsCodec.ts +``` + +`MSSQL_HOST`, `MSSQL_PORT`, and `MSSQL_PASSWORD` override the local connection. +Live benchmarks use SQL authentication as `sa` with TLS and explicitly trust the +disposable container's certificate. Tests create and remove their own objects; +do not point them at a production database. + +## Method + +`TdsClient.ts` compares a native session and tedious 20.0.0 in the same process. +Both use Effect callbacks, TLS, identical session settings and row-object +conversion. Results and session defaults must match before timing. Each workload +warms both drivers for 300 ms, then measures five alternating pairs of 750 ms. +`BENCH_ROUNDS` and `BENCH_DURATION_MS` control those values. Transactions count +one begin/insert/rollback cycle as an operation; the native driver uses SQL +batches for transaction control and tedious uses its transaction API. + +`TdsCodec.ts` reuses the DONEPROC payload/workload from +`tedious/benchmarks/token-parser/done-token.js`, with identical chunks and a +callback per token. It verifies token counts and alternates seven pairs after +warmup. `TOKEN_COUNT`, `REPEATS`, `BENCH_ROUNDS`, and `FRAGMENT_BYTES` control it. +This is a narrow parser microbenchmark, not a complete codec performance claim. + +## Local results, 2026-09-08 + +Node 24.20.0, SQL Server 2022 CU26 (16.0.4265.3), Linux amd64 container under +Docker on an ARM Mac. This was not an isolated performance host. Raw samples +are in [results.jsonl](./results.jsonl); differences are median paired throughput +changes, not the ratio of independent medians. + +| Workload | Native operations/s | Tedious operations/s | Paired change | +| ------------------------- | ------------------: | -------------------: | ------------: | +| Parameterized SELECT | 2,265 | 2,044 | +10.8% | +| 100 rows × 3 columns | 1,186 | 1,062 | +9.3% | +| 100 rows × 20 columns | 1,057 | 966 | +11.6% | +| Large Unicode result | 856 | 919 | −0.7% | +| Begin / insert / rollback | 351 | 319 | +11.0% | +| DONEPROC tokens | 9,037,162 | 4,951,068 | +83.5% | + +Samples contain substantial outliers. A codec run briefly overlapped the tail +of the live run, and unrelated host activity was not controlled. Treat these +numbers as directional evidence and rerun longer, isolated trials before making +release claims. No latency percentiles, memory/GC measurements, remote-server +results, or concurrent pool load are established by this harness. + +## Coverage and remaining validation + +Tests cover packet/token fragmentation and bounds, NTLMv2 published vectors and +a simulated exchange, SSRP discovery, routing, retries, cancellation races and +timeouts. Live SQL tests cover TLS, SQL authentication, scalar/LOB/TVP codecs, +procedures, output parameters, errors, transactions and public adapter behavior. +Existing persistence/cache/queue integration tests also run against the container. + +Windows-domain NTLM interoperability and Extended Protection, Azure/Entra +authentication, and other SQL Server versions remain outside verified coverage. diff --git a/packages/sql/mssql/benchmark/TdsClient.ts b/packages/sql/mssql/benchmark/TdsClient.ts new file mode 100644 index 00000000000..d170da85bbb --- /dev/null +++ b/packages/sql/mssql/benchmark/TdsClient.ts @@ -0,0 +1,176 @@ +import * as Native from "#tds/tdsConnection" +import { TYPES } from "#tds/tdsRequest" +import { Effect } from "effect" +import { strict as assert } from "node:assert" +import { performance } from "node:perf_hooks" +import * as Tedious from "tedious" + +const config = { + server: process.env.MSSQL_HOST ?? "127.0.0.1", + port: Number(process.env.MSSQL_PORT ?? 14339), + username: "sa", + password: process.env.MSSQL_PASSWORD ?? "Effect_Tds_Test_7426!", + encrypt: true, + trustServer: true +} +const rounds = Number(process.env.BENCH_ROUNDS ?? 5) +const duration = Number(process.env.BENCH_DURATION_MS ?? 750) +if (!Number.isSafeInteger(rounds) || rounds < 1 || !Number.isFinite(duration) || duration <= 0) { + throw new Error("BENCH_ROUNDS must be a positive integer and BENCH_DURATION_MS must be positive") +} + +const baseline = Effect.acquireRelease( + Effect.callback((resume) => { + const conn = new Tedious.Connection({ + server: config.server, + authentication: { type: "default", options: { userName: config.username, password: config.password } }, + options: { + port: config.port, + encrypt: true, + trustServerCertificate: true, + rowCollectionOnRequestCompletion: true + } + }) + conn.on("error", () => {}) + conn.connect((error) => resume(error ? Effect.fail(error) : Effect.succeed(conn))) + return Effect.sync(() => conn.close()) + }), + (conn) => Effect.sync(() => conn.close()) +) + +const tediousQuery = (conn: Tedious.Connection, query: string, parameter: boolean) => + Effect.callback, Error>((resume) => { + const request = new Tedious.Request(query, (error, _count, rows) => { + if (error) { + resume(Effect.fail(error)) + return + } + resume(Effect.succeed(rows.map((columns: Array) => { + const row: Record = {} + for (const column of columns) { + if (column.metadata.colName === "__proto__") { + Object.defineProperty(row, column.metadata.colName, { + value: column.value, + enumerable: true, + configurable: true, + writable: true + }) + } else { + row[column.metadata.colName] = column.value + } + } + return row + }))) + }) + if (parameter) request.addParameter("value", Tedious.TYPES.Float, 42) + conn.execSql(request) + return Effect.sync(() => { + conn.cancel() + }) + }) + +const tediousControl = (conn: Tedious.Connection, method: "beginTransaction" | "rollbackTransaction") => + Effect.callback((resume) => { + conn[method]((error) => resume(error ? Effect.fail(error) : Effect.void)) + }) + +const measure = (query: Effect.Effect, milliseconds: number) => + Effect.gen(function*() { + const start = performance.now() + let count = 0 + while (performance.now() - start < milliseconds) { + yield* query + count++ + } + return count * 1000 / (performance.now() - start) + }) + +const median = (values: ReadonlyArray) => { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +const program = Effect.scoped(Effect.gen(function*() { + const native = yield* Native.make(config) + const tedious = yield* baseline + const sessionSettings = "SELECT @@OPTIONS AS flags, @@DATEFIRST AS firstDay, @@TEXTSIZE AS [textSize]" + assert.deepEqual( + (yield* native.query(sessionSettings)).rows, + yield* tediousQuery(tedious, sessionSettings, false), + "session defaults differ" + ) + const version = yield* native.query("SELECT @@VERSION AS version") + console.log(JSON.stringify({ node: process.version, server: version.rows[0].version, rounds, duration, tls: true })) + const workloads = [ + { name: "parameterized-select", sql: "SELECT @value AS value", parameter: true }, + { + name: "100-rows-3-columns", + sql: + "SELECT TOP (100) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n, N'λ hello' AS text, CAST(1.5 AS float) AS value FROM sys.all_objects", + parameter: false + }, + { + name: "100-rows-20-columns", + sql: `SELECT TOP (100) ${Array.from({ length: 20 }, (_, i) => `${i} AS c${i}`).join(",")} FROM sys.all_objects`, + parameter: false + }, + { name: "large-unicode", sql: "SELECT REPLICATE(CAST(N'λ' AS nvarchar(max)), 10000) AS text", parameter: false }, + { + name: "transaction-insert-rollback", + sql: "DECLARE @t TABLE(value float); INSERT INTO @t VALUES(@value)", + parameter: true + } + ] + for (const workload of workloads) { + const nativeQuery = native.query( + workload.sql, + workload.parameter ? [{ name: "value", type: TYPES.Float, value: 42 }] : [] + ) + .pipe(Effect.map((result) => result.rows)) + const baselineQuery = tediousQuery(tedious, workload.sql, workload.parameter) + const a = workload.name === "transaction-insert-rollback" + ? native.batch("BEGIN TRAN").pipe(Effect.andThen(nativeQuery), Effect.tap(() => native.batch("ROLLBACK TRAN"))) + : nativeQuery + const b = workload.name === "transaction-insert-rollback" + ? tediousControl(tedious, "beginTransaction").pipe( + Effect.andThen(baselineQuery), + Effect.tap(() => tediousControl(tedious, "rollbackTransaction")) + ) + : baselineQuery + assert.deepEqual(yield* a, yield* b, `${workload.name}: native and tedious results differ`) + yield* measure(a, 300) + yield* measure(b, 300) + const nativeRates: Array = [] + const tediousRates: Array = [] + const deltas: Array = [] + for (let i = 0; i < rounds; i++) { + let n: number + let t: number + if (i % 2 === 0) { + n = yield* measure(a, duration) + t = yield* measure(b, duration) + } else { + t = yield* measure(b, duration) + n = yield* measure(a, duration) + } + nativeRates.push(n) + tediousRates.push(t) + deltas.push((n / t - 1) * 100) + } + console.log( + JSON.stringify({ + workload: workload.name, + nativeQueriesPerSecond: median(nativeRates), + tediousQueriesPerSecond: median(tediousRates), + medianPairedDeltaPercent: median(deltas), + nativeRates, + tediousRates + }) + ) + } +})) + +Effect.runPromise(program).catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/sql/mssql/benchmark/TdsCodec.ts b/packages/sql/mssql/benchmark/TdsCodec.ts new file mode 100644 index 00000000000..fe540a6f378 --- /dev/null +++ b/packages/sql/mssql/benchmark/TdsCodec.ts @@ -0,0 +1,92 @@ +import { TokenParser } from "#tds/tdsToken" +import { strict as assert } from "node:assert" +import { Buffer } from "node:buffer" +import { createRequire } from "node:module" +import { performance } from "node:perf_hooks" + +const require = createRequire(import.meta.url) +const { Parser } = require("tedious/lib/token/token-stream-parser.js") + +// Same DONEPROC workload as tedious/benchmarks/token-parser/done-token.js. +// Both drivers consume identical chunks and invoke one callback per token. +const tokenCount = Number(process.env.TOKEN_COUNT ?? 10000) +const repeats = Number(process.env.REPEATS ?? 100) +const rounds = Number(process.env.BENCH_ROUNDS ?? 7) +for (const value of [tokenCount, repeats, rounds]) { + if (!Number.isSafeInteger(value) || value < 1) throw new Error("Benchmark counts must be positive safe integers") +} +const data = Buffer.from("FE0000E0000000000000000000".repeat(tokenCount), "hex") +const fragment = Number(process.env.FRAGMENT_BYTES ?? data.length) +if (!Number.isSafeInteger(fragment) || fragment < 1) throw new Error("FRAGMENT_BYTES must be a positive safe integer") +const chunks: Array = [] +for (let offset = 0; offset < data.length; offset += fragment) chunks.push(data.subarray(offset, offset + fragment)) + +const native = async () => { + let count = 0 + const parser = new TokenParser() + for (let i = 0; i < repeats; i++) for (const chunk of chunks) parser.push(chunk, () => count++) + parser.end() + assert.equal(count, tokenCount * repeats) +} + +const tedious = () => + new Promise((resolve, reject) => { + let count = 0 + const input = (async function*() { + for (let i = 0; i < repeats; i++) for (const chunk of chunks) yield chunk + })() + const parser = new Parser(input, { token() {} }, { + onDoneProc() { + count++ + } + }, { tdsVersion: "7_4" }) + parser.parser.on("error", reject) + parser.on("end", () => { + try { + assert.equal(count, tokenCount * repeats) + resolve() + } catch (error) { + reject(error) + } + }) + }) + +const measure = async (run: () => Promise) => { + const start = performance.now() + await run() + return tokenCount * repeats * 1000 / (performance.now() - start) +} +const median = (values: ReadonlyArray) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] + +await native() +await tedious() +const nativeRates: Array = [] +const tediousRates: Array = [] +const deltas: Array = [] +for (let i = 0; i < rounds; i++) { + let n: number + let t: number + if (i % 2 === 0) { + n = await measure(native) + t = await measure(tedious) + } else { + t = await measure(tedious) + n = await measure(native) + } + nativeRates.push(n) + tediousRates.push(t) + deltas.push((n / t - 1) * 100) +} +console.log(JSON.stringify({ + workload: "doneproc", + node: process.version, + tokenCount, + repeats, + fragment, + rounds, + nativeTokensPerSecond: median(nativeRates), + tediousTokensPerSecond: median(tediousRates), + medianPairedDeltaPercent: median(deltas), + nativeRates, + tediousRates +})) diff --git a/packages/sql/mssql/benchmark/results.jsonl b/packages/sql/mssql/benchmark/results.jsonl new file mode 100644 index 00000000000..0c3d1502445 --- /dev/null +++ b/packages/sql/mssql/benchmark/results.jsonl @@ -0,0 +1,6 @@ +{"workload":"parameterized-select","nativeQueriesPerSecond":2265.2614731987064,"tediousQueriesPerSecond":2043.5683520431644,"medianPairedDeltaPercent":10.84833403951928,"nativeRates":[1294.7784488760708,2195.8242140244884,2265.2614731987064,2310.6830400654126,2419.094855341625],"tediousRates":[1678.9299619709038,2005.9189028926053,2043.5683520431644,2063.1820611000603,2090.4983815469495]} +{"workload":"100-rows-3-columns","nativeQueriesPerSecond":1186.2084612254866,"tediousQueriesPerSecond":1061.6600954058379,"medianPairedDeltaPercent":9.331439883834891,"nativeRates":[1120.2718145475224,1164.065406906648,1266.4828848022862,1268.8267195842766,1186.2084612254866],"tediousRates":[1061.6600954058379,1037.4460532879934,1158.3885533273224,1187.2882207116845,956.4242520817306]} +{"workload":"100-rows-20-columns","nativeQueriesPerSecond":1057.4548234390074,"tediousQueriesPerSecond":966.4625278742973,"medianPairedDeltaPercent":11.551534344184233,"nativeRates":[1088.376711153324,966.434937474521,950.3656123822867,1057.4548234390074,1091.959111227097],"tediousRates":[982.2087936673535,966.4625278742973,429.49650344303546,666.7676115640329,978.8830943893033]} +{"workload":"large-unicode","nativeQueriesPerSecond":855.8934412665619,"tediousQueriesPerSecond":918.9355149015155,"medianPairedDeltaPercent":-0.6830727590042995,"nativeRates":[1157.8768017084358,983.4798017336628,821.667792520227,855.8934412665619,804.1818789130449],"tediousRates":[1069.9053635304724,990.2438879801604,916.541084995899,918.9355149015155,800.6786450945255]} +{"workload":"transaction-insert-rollback","nativeQueriesPerSecond":350.56131599060745,"tediousQueriesPerSecond":318.9328328809418,"medianPairedDeltaPercent":11.007934004154718,"nativeRates":[320.49403040371834,349.25801002250546,350.56131599060745,361.91099650596277,353.45551223823895],"tediousRates":[379.1178140772397,314.62436730822657,335.9967757908749,318.9328328809418,206.5061081675671]} +{"workload":"doneproc","node":"v24.20.0","tokenCount":10000,"repeats":100,"fragment":130000,"rounds":7,"nativeTokensPerSecond":9037161.966764053,"tediousTokensPerSecond":4951067.976306658,"medianPairedDeltaPercent":83.47851475575712,"nativeRates":[8868642.133643469,9281941.315798532,9099470.865769159,8554280.270146217,9037161.966764053,9219107.060337914,8757618.723249363],"tediousRates":[4891930.145331272,5009275.500118927,4135448.339462411,4254080.632193138,4981255.957239664,5024624.8573628375,4951067.976306658]} diff --git a/packages/sql/mssql/package.json b/packages/sql/mssql/package.json index 4e6fd7be0c3..5c2503aaabd 100644 --- a/packages/sql/mssql/package.json +++ b/packages/sql/mssql/package.json @@ -24,6 +24,9 @@ "database" ], "sideEffects": [], + "imports": { + "#tds/*": "./src/internal/*.ts" + }, "exports": { "./package.json": "./package.json", ".": "./src/index.ts", @@ -62,12 +65,14 @@ }, "devDependencies": { "@testcontainers/mssqlserver": "^12.1.0", - "effect": "workspace:^" + "effect": "workspace:^", + "tedious": "^20.0.0" }, "peerDependencies": { "effect": "workspace:^" }, "dependencies": { - "tedious": "^20.0.0" + "iconv-lite": "^0.7.0", + "js-md4": "^0.3.2" } } diff --git a/packages/sql/mssql/src/MssqlClient.ts b/packages/sql/mssql/src/MssqlClient.ts index b7c3f64e872..4a98053436f 100644 --- a/packages/sql/mssql/src/MssqlClient.ts +++ b/packages/sql/mssql/src/MssqlClient.ts @@ -1,12 +1,12 @@ /** - * Microsoft SQL Server client implementation for Effect SQL, backed by the - * `tedious` driver. + * Microsoft SQL Server client implementation for Effect SQL, built on the native + * TDS protocol. * * This module provides the `MssqlClient` service, constructors, layers, and SQL - * Server statement compiler. `make` creates a pooled Tedious client, checks the + * Server statement compiler. `make` creates a pooled native TDS client, checks the * connection with `SELECT 1`, maps SQL Server failures to `SqlError`, and * supports transactions with savepoints. The SQL Server-specific service adds - * typed Tedious parameters with `param`, stored procedure calls with `call`, + * typed SQL Server parameters with `param`, stored procedure calls with `call`, * direct or config-backed layers, and default parameter type mappings. * Streaming queries are not implemented by this driver. * @@ -19,7 +19,6 @@ import * as Effect from "effect/Effect" import { identity } from "effect/Function" import * as Layer from "effect/Layer" import * as Pool from "effect/Pool" -import * as Rec from "effect/Record" import * as Redacted from "effect/Redacted" import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" @@ -40,11 +39,9 @@ import { UnknownError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" -import { Buffer } from "node:buffer" -import * as Tedious from "tedious" -import type { ConnectionOptions } from "tedious/lib/connection.ts" -import type { DataType } from "tedious/lib/data-type.ts" -import type { ParameterOptions } from "tedious/lib/request.ts" +import * as TdsConnection from "./internal/tdsConnection.ts" +import * as TdsRequest from "./internal/tdsRequest.ts" +import type { DataType, ParameterOptions } from "./internal/tdsRequest.ts" import type { Parameter } from "./Parameter.ts" import type * as Procedure from "./Procedure.ts" @@ -216,12 +213,15 @@ export interface MssqlClientConfig { */ readonly trustServer?: boolean | undefined readonly port?: number | undefined + /** Authentication method: `default` (SQL credentials) or `ntlm` (requires `domain`). */ readonly authType?: string | undefined readonly database?: string | undefined readonly username?: string | undefined readonly password?: Redacted.Redacted | undefined readonly connectTimeout?: Duration.Input | undefined readonly cancelTimeout?: Duration.Input | undefined + /** Time before requesting cancellation. Defaults to 15 seconds; zero disables the request timer. Cancellation is drained before reuse. */ + readonly requestTimeout?: Duration.Input | undefined readonly connectionRetryInterval?: Duration.Input | undefined readonly multiSubnetFailover?: boolean | undefined readonly maxRetriesOnTransientErrors?: number | undefined @@ -287,161 +287,81 @@ export const make = ( let pool: Pool.Pool const makeConnection = Effect.gen(function*() { - const conn = new Tedious.Connection({ - options: { - port: options.port, - database: options.database, - trustServerCertificate: options.trustServer ?? false, - multiSubnetFailover: options.multiSubnetFailover, - connectTimeout: options.connectTimeout - ? Duration.toMillis(Duration.fromInputUnsafe(options.connectTimeout)) - : undefined, - rowCollectionOnRequestCompletion: true, - useColumnNames: false, - instanceName: options.instanceName, - encrypt: options.encrypt ?? true, - cancelTimeout: options.cancelTimeout - ? Duration.toMillis(Duration.fromInputUnsafe(options.cancelTimeout)) - : undefined, - connectionRetryInterval: options.connectionRetryInterval - ? Duration.toMillis(Duration.fromInputUnsafe(options.connectionRetryInterval)) - : undefined, - maxRetriesOnTransientErrors: options.maxRetriesOnTransientErrors - } as ConnectionOptions, - server: options.server, - authentication: { - type: (options.authType as any) ?? "default", - options: { - domain: options.domain, - userName: options.username, - password: options.password - ? Redacted.value(options.password) - : undefined - } - } - }) - - yield* Effect.addFinalizer(() => Effect.sync(() => conn.close())) - - yield* Effect.callback((resume) => { - conn.connect((cause) => { - if (cause) { - resume( - Effect.fail(new SqlError({ reason: classifyError(cause, "Failed to connect", "connect", "connection") })) - ) - } else { - resume(Effect.void) - } - }) - }) - - const run = ( - sql: string, - values?: ReadonlyArray, - rowsAsArray = false - ) => - Effect.callback((resume) => { - const req = new Tedious.Request(sql, (cause, _rowCount, result) => { - if (cause) { - resume( - Effect.fail(new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })) - ) - return - } - - if (rowsAsArray) { - result = result.map((row: any) => row.map((_: any) => _.value)) - } else { - result = rowsToObjects(result) - } - - resume(Effect.succeed(result)) + if (options.authType && options.authType !== "default" && options.authType !== "ntlm") { + return yield* Effect.fail( + new SqlError({ + reason: new AuthenticationError({ + cause: undefined, + message: `Unsupported native TDS authentication: ${options.authType}`, + operation: "connect" + }) }) - - if (values) { - for (let i = 0, len = values.length; i < len; i++) { - const value = values[i] - const name = numberToParamName(i) - - if (isMssqlParam(value)) { - req.addParameter(name, value.paramA, value.paramB, value.paramC) - } else { - const kind = Statement.primitiveKind(value) - const type = parameterTypes[kind] - req.addParameter(name, type, value) - } - } - } - - conn.cancel() - conn.execSql(req) - return Effect.sync(() => conn.cancel()) - }) - - const runProcedure = ( - procedure: Procedure.ProcedureWithValues, - transformRows: ((rows: ReadonlyArray) => ReadonlyArray) | undefined - ) => - Effect.callback((resume) => { - const result: Record = {} - - const req = new Tedious.Request( - escape(procedure.name), - (cause, _, rows) => { - if (cause) { - resume( - Effect.fail(new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })) - ) - } else { - rows = rowsToObjects(rows) - if (transformRows) { - rows = transformRows(rows) as any - } - resume( - Effect.succeed({ - output: result, - rows - }) - ) - } - } + ) + } + const mapError = (error: SqlError) => + new SqlError({ + reason: classifyError( + error.reason.cause ?? error, + error.message, + "execute", + error.reason._tag === "ConnectionError" ? "connection" : "unknown" ) - - for (const name in procedure.params) { - const param = procedure.params[name] - const value = procedure.values[name] - req.addParameter(name, param.type, value, param.options) + }) + const conn = yield* TdsConnection.make({ + server: options.server, + port: options.port, + instanceName: options.instanceName, + multiSubnetFailover: options.multiSubnetFailover, + authType: options.authType as "default" | "ntlm" | undefined, + domain: options.domain, + maxRetriesOnTransientErrors: options.maxRetriesOnTransientErrors, + connectionRetryIntervalMs: options.connectionRetryInterval + ? Duration.toMillis(Duration.fromInputUnsafe(options.connectionRetryInterval)) + : undefined, + database: options.database, + username: options.username, + password: options.password ? Redacted.value(options.password) : undefined, + encrypt: options.encrypt, + trustServer: options.trustServer, + connectTimeoutMs: options.connectTimeout + ? Duration.toMillis(Duration.fromInputUnsafe(options.connectTimeout)) + : undefined, + cancelTimeoutMs: options.cancelTimeout + ? Duration.toMillis(Duration.fromInputUnsafe(options.cancelTimeout)) + : undefined, + requestTimeoutMs: options.requestTimeout !== undefined + ? Duration.toMillis(Duration.fromInputUnsafe(options.requestTimeout)) + : undefined + }).pipe(Effect.mapError(mapError)) + + const parameters = (values: ReadonlyArray): Array => + values.map((value, i) => { + if (isMssqlParam(value)) { + return { name: numberToParamName(i), type: value.paramA, value: value.paramB, options: value.paramC } } - - for (const name in procedure.outputParams) { - const param = procedure.outputParams[name] - req.addOutputParameter(name, param.type, undefined, param.options) + const kind = Statement.primitiveKind(value) + return { + name: numberToParamName(i), + type: parameterTypes[kind], + value: value instanceof Int8Array + ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + : value } - - req.on("returnValue", (name, value) => { - Rec.assignProperty(result, name, value) - }) - - conn.cancel() - conn.callProcedure(req) - return Effect.sync(() => conn.cancel()) }) + const run = (sql: string, values: ReadonlyArray, rowsAsArray = false) => + conn.query(sql, parameters(values), rowsAsArray).pipe( + Effect.map((result) => result.rows), + Effect.mapError(mapError) + ) + const batch = (sql: string) => conn.batch(sql).pipe(Effect.asVoid, Effect.mapError(mapError)) const connection = identity({ execute(sql, params, transformRows) { - return transformRows - ? Effect.map(run(sql, params), transformRows) - : run(sql, params) - }, - executeRaw(sql, params) { - return run(sql, params) - }, - executeValues(sql, params) { - return run(sql, params, true) - }, - executeValuesUnprepared(sql, params) { - return run(sql, params, true) + return transformRows ? Effect.map(run(sql, params), transformRows) : run(sql, params) }, + executeRaw: (sql, params) => run(sql, params), + executeValues: (sql, params) => run(sql, params, true), + executeValuesUnprepared: (sql, params) => run(sql, params, true), executeUnprepared(sql, params, transformRows) { return this.execute(sql, params, transformRows) }, @@ -449,78 +369,38 @@ export const make = ( return Stream.die("executeStream not implemented") }, call(procedure, transformRows) { - return runProcedure(procedure, transformRows) + const params: Array = [] + for (const name in procedure.params) { + const param = procedure.params[name] + params.push({ name, type: param.type, value: procedure.values[name], options: param.options }) + } + for (const name in procedure.outputParams) { + const param = procedure.outputParams[name] + params.push({ name, type: param.type, value: null, options: param.options, output: true }) + } + return conn.call(procedure.name, params).pipe( + Effect.map((result) => ({ + output: result.output, + rows: transformRows ? transformRows(result.rows) : result.rows + })), + Effect.mapError(mapError) + ) }, - begin: Effect.callback((resume) => { - conn.beginTransaction((cause) => { - if (cause) { - resume( - Effect.fail( - new SqlError({ - reason: classifyError(cause, "Failed to begin transaction", "beginTransaction") - }) - ) - ) - } else { - resume(Effect.void) - } - }) - }), - commit: Effect.callback((resume) => { - conn.commitTransaction((cause) => { - if (cause) { - resume( - Effect.fail( - new SqlError({ - reason: classifyError(cause, "Failed to commit transaction", "commitTransaction") - }) - ) - ) - } else { - resume(Effect.void) - } - }) - }), - savepoint: (name: string) => - Effect.callback((resume) => { - conn.saveTransaction((cause) => { - if (cause) { - resume( - Effect.fail( - new SqlError({ reason: classifyError(cause, "Failed to create savepoint", "createSavepoint") }) - ) - ) - } else { - resume(Effect.void) - } - }, name) - }), - rollback: (name?: string) => - Effect.callback((resume) => { - conn.rollbackTransaction((cause) => { - if (cause) { - resume( - Effect.fail( - new SqlError({ - reason: classifyError(cause, "Failed to rollback transaction", "rollbackTransaction") - }) - ) - ) - } else { - resume(Effect.void) - } - }, name) - }) + begin: batch("BEGIN TRANSACTION"), + commit: batch("COMMIT TRANSACTION"), + savepoint: (name) => batch(`SAVE TRANSACTION ${escape(name)}`), + rollback: (name) => + batch(name ? `ROLLBACK TRANSACTION ${escape(name)}` : "IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION") }) - yield* Effect.callback((resume) => { - conn.on("error", (_) => resume(Effect.fail(_))) + yield* Effect.callback((resume) => { + const remove = conn.onClose(() => resume(Effect.void)) + return Effect.sync(remove) }).pipe( - Effect.catch(() => Pool.invalidate(pool, connection)), + Effect.flatMap(() => Pool.invalidate(pool, connection)), Effect.interruptible, Effect.forkScoped ) - return connection }) @@ -554,6 +434,10 @@ export const make = ( ) const transactionService = TransactionConnection(clientIdCounter++) + const getConnection = Effect.flatMap( + Effect.serviceOption(transactionService), + (transaction) => transaction._tag === "Some" ? Effect.succeed(transaction.value[0]) : Pool.get(pool) + ) const withTransaction = Client.makeWithTransaction({ transactionService, @@ -593,9 +477,9 @@ export const make = ( A >( procedure: Procedure.ProcedureWithValues - ) => Effect.scoped(Effect.flatMap(Pool.get(pool), (_) => _.call(procedure, transformRows))), + ) => Effect.scoped(Effect.flatMap(getConnection, (_) => _.call(procedure, transformRows))), withoutTransforms() { - const statement = Statement.make(Pool.get(pool), compiler.withoutTransform, spanAttributes, undefined) + const statement = Statement.make(getConnection, compiler.withoutTransform, spanAttributes, undefined) const client = Object.assign( statement, this, @@ -607,7 +491,7 @@ export const make = ( A >( procedure: Procedure.ProcedureWithValues - ) => Effect.scoped(Effect.flatMap(Pool.get(pool), (_) => _.call(procedure, undefined))) + ) => Effect.scoped(Effect.flatMap(getConnection, (_) => _.call(procedure, undefined))) } ) ;(client as any).safe = client @@ -708,32 +592,21 @@ function numberToParamName(n: number) { return `${Math.ceil(n + 1)}` } -const byteArrayParameterType: DataType = { - ...Tedious.TYPES.VarBinary, - validate(value, collation, options) { - return Tedious.TYPES.VarBinary.validate( - Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength), - collation, - options - ) - } -} - /** - * Default mapping from Effect SQL primitive value kinds to Tedious SQL Server parameter data types. + * Default mapping from Effect SQL primitive value kinds to SQL Server parameter data types. * * @category constants * @since 4.0.0 */ export const defaultParameterTypes: Record = { - string: Tedious.TYPES.NVarChar, - number: Tedious.TYPES.Float, - bigint: Tedious.TYPES.BigInt, - boolean: Tedious.TYPES.Bit, - Date: Tedious.TYPES.DateTime, - Uint8Array: byteArrayParameterType, - Int8Array: byteArrayParameterType, - null: Tedious.TYPES.Bit + string: TdsRequest.TYPES.NVarChar, + number: TdsRequest.TYPES.Float, + bigint: TdsRequest.TYPES.BigInt, + boolean: TdsRequest.TYPES.Bit, + Date: TdsRequest.TYPES.DateTime, + Uint8Array: TdsRequest.TYPES.VarBinary, + Int8Array: TdsRequest.TYPES.VarBinary, + null: TdsRequest.TYPES.Bit } // custom types @@ -751,19 +624,3 @@ interface MssqlParam extends const mssqlParam = Statement.custom("MssqlParam") const isMssqlParam = Statement.isCustom("MssqlParam") - -function rowsToObjects(rows: ReadonlyArray) { - const newRows = new Array(rows.length) - - for (let i = 0, len = rows.length; i < len; i++) { - const row = rows[i] - const newRow: any = {} - for (let j = 0, columnLen = row.length; j < columnLen; j++) { - const column = row[j] - Rec.assignProperty(newRow, column.metadata.colName, column.value) - } - newRows[i] = newRow - } - - return newRows -} diff --git a/packages/sql/mssql/src/MssqlTypes.ts b/packages/sql/mssql/src/MssqlTypes.ts new file mode 100644 index 00000000000..b862e5ea6d4 --- /dev/null +++ b/packages/sql/mssql/src/MssqlTypes.ts @@ -0,0 +1,283 @@ +/** + * SQL Server parameter types for the native TDS client. + * + * Pass these values to `MssqlClient.param`, `Procedure.param`, or + * `Procedure.outputParam`. Parameter encoding validates values before a + * request is written to the connection. + * + * @since 4.0.0 + */ +import * as internal from "./internal/tdsRequest.ts" + +/** + * A SQL Server parameter type descriptor. + * + * @category models + * @since 4.0.0 + */ +export type DataType = internal.DataType + +/** + * Explicit length, precision, and scale for a parameter. + * A length of `Infinity` selects a MAX type. + * + * @category models + * @since 4.0.0 + */ +export type ParameterOptions = internal.ParameterOptions + +/** + * A named SQL Server table type and its input rows. + * + * @category models + * @since 4.0.0 + */ +export type Table = internal.Table + +/** + * The SQL Server TinyInt parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const TinyInt: DataType = internal.TYPES.TinyInt + +/** + * The SQL Server SmallInt parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const SmallInt: DataType = internal.TYPES.SmallInt + +/** + * The SQL Server Int parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Int: DataType = internal.TYPES.Int + +/** + * The SQL Server BigInt parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const BigInt: DataType = internal.TYPES.BigInt + +/** + * The SQL Server Bit parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Bit: DataType = internal.TYPES.Bit + +/** + * The SQL Server Real parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Real: DataType = internal.TYPES.Real + +/** + * The SQL Server Float parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Float: DataType = internal.TYPES.Float + +/** + * The SQL Server NVarChar parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const NVarChar: DataType = internal.TYPES.NVarChar + +/** + * The SQL Server NChar parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const NChar: DataType = internal.TYPES.NChar + +/** + * The SQL Server VarChar parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const VarChar: DataType = internal.TYPES.VarChar + +/** + * The SQL Server Char parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Char: DataType = internal.TYPES.Char + +/** + * The SQL Server VarBinary parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const VarBinary: DataType = internal.TYPES.VarBinary + +/** + * The SQL Server Binary parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Binary: DataType = internal.TYPES.Binary + +/** + * The SQL Server Date parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Date: DataType = internal.TYPES.Date + +/** + * The SQL Server Time parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Time: DataType = internal.TYPES.Time + +/** + * The SQL Server DateTime parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const DateTime: DataType = internal.TYPES.DateTime + +/** + * The SQL Server DateTime2 parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const DateTime2: DataType = internal.TYPES.DateTime2 + +/** + * The SQL Server DateTimeOffset parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const DateTimeOffset: DataType = internal.TYPES.DateTimeOffset + +/** + * The SQL Server SmallDateTime parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const SmallDateTime: DataType = internal.TYPES.SmallDateTime + +/** + * The SQL Server UniqueIdentifier parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const UniqueIdentifier: DataType = internal.TYPES.UniqueIdentifier + +/** + * The SQL Server Decimal parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Decimal: DataType = internal.TYPES.Decimal + +/** + * The SQL Server Numeric parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Numeric: DataType = internal.TYPES.Numeric + +/** + * The SQL Server Money parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Money: DataType = internal.TYPES.Money + +/** + * The SQL Server SmallMoney parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const SmallMoney: DataType = internal.TYPES.SmallMoney + +/** + * The SQL Server Text parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Text: DataType = internal.TYPES.Text + +/** + * The SQL Server NText parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const NText: DataType = internal.TYPES.NText + +/** + * The SQL Server Image parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Image: DataType = internal.TYPES.Image + +/** + * The SQL Server Xml parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const Xml: DataType = internal.TYPES.Xml + +/** + * The SQL Server TVP parameter type. + * + * @category constructors + * @since 4.0.0 + */ +export const TVP: DataType = internal.TYPES.TVP + +/** + * The SQL Server UDT descriptor. UDT results decode to bytes; parameter encoding is not supported. + * + * @category constants + * @since 4.0.0 + */ +export const UDT: DataType = internal.TYPES.UDT + +/** + * The SQL Server Variant descriptor. Variant results decode to their underlying values; parameter encoding is not supported. + * + * @category constants + * @since 4.0.0 + */ +export const Variant: DataType = internal.TYPES.Variant diff --git a/packages/sql/mssql/src/Parameter.ts b/packages/sql/mssql/src/Parameter.ts index 4c50d1c0833..7c11b75baf5 100644 --- a/packages/sql/mssql/src/Parameter.ts +++ b/packages/sql/mssql/src/Parameter.ts @@ -2,18 +2,17 @@ * Typed SQL Server stored procedure parameter metadata. * * This module builds {@link Parameter} values that pair a stored procedure - * parameter name with a Tedious `DataType`, Tedious `ParameterOptions`, and a + * parameter name with an `MssqlTypes.DataType`, `MssqlTypes.ParameterOptions`, and a * phantom TypeScript value type. `Procedure.param` and * `Procedure.outputParam` use this metadata, and `MssqlClient.call` forwards it - * to Tedious when registering input and output parameters. + * to the native TDS encoder for input and output parameters. * * @see {@link make} for constructing parameter metadata directly. * * @since 4.0.0 */ import { identity } from "effect/Function" -import type { DataType } from "tedious/lib/data-type.ts" -import type { ParameterOptions } from "tedious/lib/request.ts" +import type { DataType, ParameterOptions } from "./MssqlTypes.ts" /** * Runtime type identifier used to mark SQL Server stored procedure parameter metadata. @@ -32,7 +31,7 @@ export const TypeId: TypeId = "~@effect/sql-mssql/Parameter" export type TypeId = "~@effect/sql-mssql/Parameter" /** - * Metadata for a SQL Server stored procedure parameter, including its name, Tedious data type, options, and phantom value type. + * Metadata for a SQL Server stored procedure parameter, including its name, SQL data type, options, and phantom value type. * * @category models * @since 4.0.0 diff --git a/packages/sql/mssql/src/Procedure.ts b/packages/sql/mssql/src/Procedure.ts index f59ccb3bf0b..3e9c4a00a8d 100644 --- a/packages/sql/mssql/src/Procedure.ts +++ b/packages/sql/mssql/src/Procedure.ts @@ -3,7 +3,7 @@ * * This module defines the `Procedure` values consumed by `MssqlClient.call`. * `make` starts a procedure definition, `param` and `outputParam` add typed - * Tedious parameter metadata, `withRows` sets the expected row type, and + * SQL Server parameter metadata, `withRows` sets the expected row type, and * `compile` binds input values before execution. The module also defines the * typed result shape for output parameters and returned rows. * @@ -14,8 +14,7 @@ import type { Pipeable } from "effect/Pipeable" import { pipeArguments } from "effect/Pipeable" import type { Covariant } from "effect/Types" import type { Row } from "effect/unstable/sql/SqlConnection" -import type { DataType } from "tedious/lib/data-type.ts" -import type { ParameterOptions } from "tedious/lib/request.ts" +import type { DataType, ParameterOptions } from "./MssqlTypes.ts" import * as Parameter from "./Parameter.ts" /** diff --git a/packages/sql/mssql/src/index.ts b/packages/sql/mssql/src/index.ts index fdec623a7a6..17b89f5f5b8 100644 --- a/packages/sql/mssql/src/index.ts +++ b/packages/sql/mssql/src/index.ts @@ -2,13 +2,6 @@ * @since 4.0.0 */ -export { - /** - * @since 4.0.0 - */ - TYPES as MssqlTypes -} from "tedious" - // @barrel: Auto-generated exports. Do not edit manually. /** @@ -21,6 +14,11 @@ export * as MssqlClient from "./MssqlClient.ts" */ export * as MssqlMigrator from "./MssqlMigrator.ts" +/** + * @since 4.0.0 + */ +export * as MssqlTypes from "./MssqlTypes.ts" + /** * @since 4.0.0 */ diff --git a/packages/sql/mssql/src/internal/js-md4.d.ts b/packages/sql/mssql/src/internal/js-md4.d.ts new file mode 100644 index 00000000000..bfbddfe0b2d --- /dev/null +++ b/packages/sql/mssql/src/internal/js-md4.d.ts @@ -0,0 +1,6 @@ +declare module "js-md4" { + const md4: { + readonly arrayBuffer: (data: Uint8Array) => ArrayBuffer + } + export default md4 +} diff --git a/packages/sql/mssql/src/internal/tdsCollation.ts b/packages/sql/mssql/src/internal/tdsCollation.ts new file mode 100644 index 00000000000..8a10a46f358 --- /dev/null +++ b/packages/sql/mssql/src/internal/tdsCollation.ts @@ -0,0 +1,331 @@ +/* + * Collation mapping tables adapted from tedious/src/collation.ts. + * + * The MIT License + * + * Copyright (c) 2010-2018 Mike D Pilsbury + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +type Encoding = + | "utf-8" + | "CP437" + | "CP850" + | "CP874" + | "CP932" + | "CP936" + | "CP949" + | "CP950" + | "CP1250" + | "CP1251" + | "CP1252" + | "CP1253" + | "CP1254" + | "CP1255" + | "CP1256" + | "CP1257" + | "CP1258" + +// http://technet.microsoft.com/en-us/library/aa176553(v=sql.80).aspx +export const codepageByLanguageId: { [key: number]: Encoding } = { + // Arabic_* + [0x0401]: "CP1256", + + // Chinese_Taiwan_Stroke_* + // Chinese_Traditional_Stroke_Count_* + // Chinese_Taiwan_Bopomofo_* + // Chinese_Traditional_Bopomofo_* + [0x0404]: "CP950", + + // Czech_* + [0x0405]: "CP1250", + + // Danish_Greenlandic_* + // Danish_Norwegian_* + [0x0406]: "CP1252", + + // Greek_* + [0x0408]: "CP1253", + + // Latin1_General_* + [0x0409]: "CP1252", + + // Traditional_Spanish_* + [0x040A]: "CP1252", + + // Finnish_Swedish_* + [0x040B]: "CP1252", + + // French_* + [0x040C]: "CP1252", + + // Hebrew_* + [0x040D]: "CP1255", + + // Hungarian_* + // Hungarian_Technical_* + [0x040E]: "CP1250", + + // Icelandic_* + [0x040F]: "CP1252", + + // Japanese_* + // Japanese_XJIS_* + // Japanese_Unicode_* + // Japanese_Bushu_Kakusu_* + [0x0411]: "CP932", + + // Korean_* + // Korean_Wansung_* + [0x0412]: "CP949", + + // Norwegian_* + [0x0414]: "CP1252", + + // Polish_* + [0x0415]: "CP1250", + + // Romansh_* + [0x0417]: "CP1252", + + // Romanian_* + [0x0418]: "CP1250", + + // Cyrillic_* + [0x0419]: "CP1251", + + // Croatian_* + [0x041A]: "CP1250", + + // Slovak_* + [0x041B]: "CP1250", + + // Albanian_* + [0x041C]: "CP1250", + + // Thai_* + [0x041E]: "CP874", + + // Turkish_* + [0x041F]: "CP1254", + + // Urdu_* + [0x0420]: "CP1256", + + // Ukrainian_* + [0x0422]: "CP1251", + + // Slovenian_* + [0x0424]: "CP1250", + + // Estonian_* + [0x0425]: "CP1257", + + // Latvian_* + [0x0426]: "CP1257", + + // Lithuanian_* + [0x0427]: "CP1257", + + // Persian_* + [0x0429]: "CP1256", + + // Vietnamese_* + [0x042A]: "CP1258", + + // Azeri_Latin_* + [0x042C]: "CP1254", + + // Upper_Sorbian_* + [0x042E]: "CP1252", + + // Macedonian_FYROM_* + [0x042F]: "CP1251", + + // Sami_Norway_* + [0x043B]: "CP1252", + + // Kazakh_* + [0x043F]: "CP1251", + + // Turkmen_* + [0x0442]: "CP1250", + + // Uzbek_Latin_* + [0x0443]: "CP1254", + + // Tatar_* + [0x0444]: "CP1251", + + // Welsh_* + [0x0452]: "CP1252", + + // Frisian_* + [0x0462]: "CP1252", + + // Bashkir_* + [0x046D]: "CP1251", + + // Mapudungan_* + [0x047A]: "CP1252", + + // Mohawk_* + [0x047C]: "CP1252", + + // Breton_* + [0x047E]: "CP1252", + + // Uighur_* + [0x0480]: "CP1256", + + // Corsican_* + [0x0483]: "CP1252", + + // Yakut_* + [0x0485]: "CP1251", + + // Dari_* + [0x048C]: "CP1256", + + // Chinese_PRC_* + // Chinese_Simplified_Pinyin_* + // Chinese_PRC_Stroke_* + // Chinese_Simplified_Stroke_Order_* + [0x0804]: "CP936", + + // Serbian_Latin_* + [0x081A]: "CP1250", + + // Azeri_Cyrillic_* + [0x082C]: "CP1251", + + // Sami_Sweden_Finland_* + [0x083B]: "CP1252", + + // Tamazight_* + [0x085F]: "CP1252", + + // Chinese_Hong_Kong_Stroke_* + [0x0C04]: "CP950", + + // Modern_Spanish_* + [0x0C0A]: "CP1252", + + // Serbian_Cyrillic_* + [0x0C1A]: "CP1251", + + // Chinese_Traditional_Pinyin_* + // Chinese_Traditional_Stroke_Order_* + [0x1404]: "CP950", + + // Bosnian_Latin_* + [0x141A]: "CP1250", + + // Bosnian_Cyrillic_* + [0x201A]: "CP1251", + + // German + // German_PhoneBook_* + [0x0407]: "CP1252", + + // Georgian_Modern_Sort_* + [0x0437]: "CP1252" +} + +export const codepageBySortId: { [key: number]: Encoding } = { + [30]: "CP437", // SQL_Latin1_General_CP437_BIN + [31]: "CP437", // SQL_Latin1_General_CP437_CS_AS + [32]: "CP437", // SQL_Latin1_General_CP437_CI_AS + [33]: "CP437", // SQL_Latin1_General_Pref_CP437_CI_AS + [34]: "CP437", // SQL_Latin1_General_CP437_CI_AI + [40]: "CP850", // SQL_Latin1_General_CP850_BIN + [41]: "CP850", // SQL_Latin1_General_CP850_CS_AS + [42]: "CP850", // SQL_Latin1_General_CP850_CI_AS + [43]: "CP850", // SQL_Latin1_General_Pref_CP850_CI_AS + [44]: "CP850", // SQL_Latin1_General_CP850_CI_AI + [49]: "CP850", // SQL_1xCompat_CP850_CI_AS + [51]: "CP1252", // SQL_Latin1_General_Cp1_CS_AS_KI_WI + [52]: "CP1252", // SQL_Latin1_General_Cp1_CI_AS_KI_WI + [53]: "CP1252", // SQL_Latin1_General_Pref_Cp1_CI_AS_KI_WI + [54]: "CP1252", // SQL_Latin1_General_Cp1_CI_AI_KI_WI + [55]: "CP850", // SQL_AltDiction_CP850_CS_AS + [56]: "CP850", // SQL_AltDiction_Pref_CP850_CI_AS + [57]: "CP850", // SQL_AltDiction_CP850_CI_AI + [58]: "CP850", // SQL_Scandinavian_Pref_CP850_CI_AS + [59]: "CP850", // SQL_Scandinavian_CP850_CS_AS + [60]: "CP850", // SQL_Scandinavian_CP850_CI_AS + [61]: "CP850", // SQL_AltDiction_CP850_CI_AS + [80]: "CP1250", // SQL_Latin1_General_1250_BIN + [81]: "CP1250", // SQL_Latin1_General_CP1250_CS_AS + [82]: "CP1250", // SQL_Latin1_General_Cp1250_CI_AS_KI_WI + [83]: "CP1250", // SQL_Czech_Cp1250_CS_AS_KI_WI + [84]: "CP1250", // SQL_Czech_Cp1250_CI_AS_KI_WI + [85]: "CP1250", // SQL_Hungarian_Cp1250_CS_AS_KI_WI + [86]: "CP1250", // SQL_Hungarian_Cp1250_CI_AS_KI_WI + [87]: "CP1250", // SQL_Polish_Cp1250_CS_AS_KI_WI + [88]: "CP1250", // SQL_Polish_Cp1250_CI_AS_KI_WI + [89]: "CP1250", // SQL_Romanian_Cp1250_CS_AS_KI_WI + [90]: "CP1250", // SQL_Romanian_Cp1250_CI_AS_KI_WI + [91]: "CP1250", // SQL_Croatian_Cp1250_CS_AS_KI_WI + [92]: "CP1250", // SQL_Croatian_Cp1250_CI_AS_KI_WI + [93]: "CP1250", // SQL_Slovak_Cp1250_CS_AS_KI_WI + [94]: "CP1250", // SQL_Slovak_Cp1250_CI_AS_KI_WI + [95]: "CP1250", // SQL_Slovenian_Cp1250_CS_AS_KI_WI + [96]: "CP1250", // SQL_Slovenian_Cp1250_CI_AS_KI_WI + [104]: "CP1251", // SQL_Latin1_General_1251_BIN + [105]: "CP1251", // SQL_Latin1_General_CP1251_CS_AS + [106]: "CP1251", // SQL_Latin1_General_CP1251_CI_AS + [107]: "CP1251", // SQL_Ukrainian_Cp1251_CS_AS_KI_WI + [108]: "CP1251", // SQL_Ukrainian_Cp1251_CI_AS_KI_WI + [112]: "CP1253", // SQL_Latin1_General_1253_BIN + [113]: "CP1253", // SQL_Latin1_General_CP1253_CS_AS + [114]: "CP1253", // SQL_Latin1_General_CP1253_CI_AS + [120]: "CP1253", // SQL_MixDiction_CP1253_CS_AS + [121]: "CP1253", // SQL_AltDiction_CP1253_CS_AS + [122]: "CP1253", // SQL_AltDiction2_CP1253_CS_AS + [124]: "CP1253", // SQL_Latin1_General_CP1253_CI_AI + [128]: "CP1254", // SQL_Latin1_General_1254_BIN + [129]: "CP1254", // SQL_Latin1_General_Cp1254_CS_AS_KI_WI + [130]: "CP1254", // SQL_Latin1_General_Cp1254_CI_AS_KI_WI + [136]: "CP1255", // SQL_Latin1_General_1255_BIN + [137]: "CP1255", // SQL_Latin1_General_CP1255_CS_AS + [138]: "CP1255", // SQL_Latin1_General_CP1255_CI_AS + [144]: "CP1256", // SQL_Latin1_General_1256_BIN + [145]: "CP1256", // SQL_Latin1_General_CP1256_CS_AS + [146]: "CP1256", // SQL_Latin1_General_CP1256_CI_AS + [152]: "CP1257", // SQL_Latin1_General_1257_BIN + [153]: "CP1257", // SQL_Latin1_General_CP1257_CS_AS + [154]: "CP1257", // SQL_Latin1_General_CP1257_CI_AS + [155]: "CP1257", // SQL_Estonian_Cp1257_CS_AS_KI_WI + [156]: "CP1257", // SQL_Estonian_Cp1257_CI_AS_KI_WI + [157]: "CP1257", // SQL_Latvian_Cp1257_CS_AS_KI_WI + [158]: "CP1257", // SQL_Latvian_Cp1257_CI_AS_KI_WI + [159]: "CP1257", // SQL_Lithuanian_Cp1257_CS_AS_KI_WI + [160]: "CP1257", // SQL_Lithuanian_Cp1257_CI_AS_KI_WI + [183]: "CP1252", // SQL_Danish_Pref_Cp1_CI_AS_KI_WI + [184]: "CP1252", // SQL_SwedishPhone_Pref_Cp1_CI_AS_KI_WI + [185]: "CP1252", // SQL_SwedishStd_Pref_Cp1_CI_AS_KI_WI + [186]: "CP1252" // SQL_Icelandic_Pref_Cp1_CI_AS_KI_WI +} + +export const encoding = (collation: Uint8Array): string | undefined => { + if (collation[3] & 0x04) return "utf-8" + if (collation[4]) return codepageBySortId[collation[4]] + return codepageByLanguageId[collation[0] | (collation[1] << 8)] +} diff --git a/packages/sql/mssql/src/internal/tdsConnection.ts b/packages/sql/mssql/src/internal/tdsConnection.ts new file mode 100644 index 00000000000..d2f0c064f9d --- /dev/null +++ b/packages/sql/mssql/src/internal/tdsConnection.ts @@ -0,0 +1,629 @@ +import * as Effect from "effect/Effect" +import type * as Scope from "effect/Scope" +import * as Semaphore from "effect/Semaphore" +import { ConnectionError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" +import { Buffer } from "node:buffer" +import * as Dgram from "node:dgram" +import * as Net from "node:net" +import { Duplex } from "node:stream" +import * as Tls from "node:tls" +import * as Ntlm from "./tdsNtlm.ts" +import * as Packet from "./tdsPacket.ts" +import * as Request from "./tdsRequest.ts" +import { Reader, type Token, TokenParser } from "./tdsToken.ts" + +export interface Config extends Packet.LoginOptions { + readonly port?: number | undefined + readonly encrypt?: boolean | undefined + readonly trustServer?: boolean | undefined + readonly connectTimeoutMs?: number | undefined + readonly cancelTimeoutMs?: number | undefined + readonly maxTokenSize?: number | undefined + readonly instanceName?: string | undefined + readonly multiSubnetFailover?: boolean | undefined + readonly authType?: "default" | "ntlm" | undefined + readonly domain?: string | undefined + readonly connectionRetryIntervalMs?: number | undefined + readonly maxRetriesOnTransientErrors?: number | undefined + readonly requestTimeoutMs?: number | undefined + readonly initializeSession?: boolean | undefined +} + +export interface Result { + readonly rows: ReadonlyArray + readonly output: Record + readonly rowCount: number + readonly returnStatus: number +} + +type Resume = (result: Effect.Effect) => void + +class RoutingChange extends Error { + readonly server: string + readonly port: number + constructor(server: string, port: number) { + super("SQL Server requested routing") + this.server = server + this.port = port + } +} + +interface Pending { + readonly resume: Resume + readonly rows: Array + readonly output: Record + readonly values: boolean + rowCount: number + returnStatus: number + error: SqlError | undefined + done: boolean + attention: boolean + cancelRequested: boolean + requestTimer?: ReturnType | undefined + cancelResume?: (() => void) | undefined + cancelTimer?: ReturnType | undefined +} + +const failure = (cause: unknown, connection = false): SqlError => + new SqlError({ + reason: connection ? + new ConnectionError({ cause, message: "TDS connection failed", operation: "connect" }) : + new UnknownError({ + cause, + message: cause instanceof Error ? cause.message : "TDS request failed", + operation: "execute" + }) + }) + +export class Session { + private socket: Net.Socket + private tls: Tls.TLSSocket | undefined + private bridge: Duplex | undefined + private readonly packets = new Packet.PacketParser() + private readonly messages = new Packet.MessageParser() + private readonly tokens: TokenParser + private state: "prelogin" | "handshake" | "login" | "ready" | "closed" = "prelogin" + private pending: Pending | undefined + private loginAck = false + private loginDone = false + private loginError: SqlError | undefined + private route: RoutingChange | undefined + private readonly loginPayload: Buffer + private ntlmChallenge: Buffer | undefined + private ntlmSent = false + private readonly connectTimer: ReturnType + private transaction = Buffer.alloc(8) + private collation = Buffer.from([0x09, 0x04, 0xd0, 0x00, 0x34]) + private packetSize: number + private readonly closedListeners = new Set<() => void>() + private readonly semaphore = Semaphore.makeUnsafe(1) + + readonly config: Config + private readonly connected: (result: Effect.Effect) => void + constructor(config: Config, connected: (result: Effect.Effect) => void) { + this.config = config + this.connected = connected + this.packetSize = config.packetSize ?? 4096 + if (config.authType === "ntlm" && !config.domain) throw new Packet.ProtocolError("NTLM requires a domain") + this.loginPayload = Packet.login(config.authType === "ntlm" ? { ...config, sspi: Ntlm.negotiate() } : config) + if (!Number.isInteger(this.packetSize) || this.packetSize < 512 || this.packetSize > 32767) { + throw new Packet.ProtocolError("Invalid packet size") + } + for (const timeout of [config.connectTimeoutMs, config.cancelTimeoutMs]) { + if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0 || timeout > 2147483647)) { + throw new Packet.ProtocolError("Invalid timeout") + } + } + if ( + config.requestTimeoutMs !== undefined && + (!Number.isFinite(config.requestTimeoutMs) || config.requestTimeoutMs < 0 || config.requestTimeoutMs > 2147483647) + ) { + throw new Packet.ProtocolError("Invalid request timeout") + } + this.tokens = new TokenParser(config.maxTokenSize) + this.socket = Net.createConnection({ + host: config.server, + port: config.port ?? 1433, + autoSelectFamily: true, + ...(config.multiSubnetFailover ? { autoSelectFamilyAttemptTimeout: 100 } : {}) + }) + this.socket.setNoDelay(true) + this.connectTimer = setTimeout( + () => this.fail(new Error("TDS connection timeout")), + config.connectTimeoutMs ?? 15000 + ) + this.socket.on("error", (error) => this.fail(error)) + this.socket.on("close", () => this.fail(new Error("TDS connection closed"))) + this.socket.on("data", (chunk: Buffer) => { + try { + if (this.bridge && this.state !== "handshake") this.bridge.push(chunk) + else this.packets.push(chunk, (packet) => this.onPacket(packet)) + } catch (error) { + this.fail(error) + } + }) + this.socket.once( + "connect", + () => this.socket.write(Packet.encode(Packet.PRELOGIN, Packet.prelogin(config.encrypt ?? true))) + ) + } + + get closed(): boolean { + return this.state === "closed" + } + + onClose(listener: () => void): () => void { + if (this.closed) listener() + else this.closedListeners.add(listener) + return () => this.closedListeners.delete(listener) + } + + close(): void { + this.fail(new Error("TDS session released")) + } + + query( + query: string, + parameters: ReadonlyArray = [], + values = false + ): Effect.Effect { + return this.request(() => Request.sql(query, parameters, this.transaction, this.collation), Packet.RPC, values) + } + + batch(query: string, timeoutMs?: number): Effect.Effect { + return this.request( + () => Buffer.concat([Packet.allHeaders(this.transaction), Buffer.from(query, "utf16le")]), + Packet.SQL_BATCH, + false, + timeoutMs + ) + } + + call(procedure: string, parameters: ReadonlyArray): Effect.Effect { + return this.request(() => Request.rpc(procedure, parameters, this.transaction, this.collation), Packet.RPC, false) + } + + private request( + payload: () => Buffer, + type: number, + values: boolean, + timeoutMs = this.config.requestTimeoutMs ?? 15000 + ): Effect.Effect { + return this.semaphore.withPermit(Effect.callback((resume) => { + if (this.state !== "ready") { + resume(Effect.fail(failure(new Error("TDS session is not ready"), true))) + return + } + let data: Buffer + try { + data = Packet.encode(type, payload(), this.packetSize) + } catch (error) { + resume(Effect.fail(failure(error))) + return + } + const pending: Pending = { + resume, + rows: [], + output: {}, + values, + rowCount: 0, + returnStatus: 0, + error: undefined, + done: false, + attention: false, + cancelRequested: false + } + this.pending = pending + this.tokens.columns = undefined + if (timeoutMs > 0) { + pending.requestTimer = setTimeout(() => { + pending.error = failure(Object.assign(new Error("TDS request timeout"), { code: "ETIMEOUT" })) + this.cancel(pending) + }, timeoutMs) + } + this.write(data) + return Effect.callback((cancelResume) => { + if (this.pending !== pending) { + cancelResume(Effect.void) + return + } + pending.cancelResume = () => cancelResume(Effect.void) + this.cancel(pending) + }) + })) + } + + private cancel(pending: Pending): void { + if (pending.cancelRequested || this.pending !== pending) return + pending.cancelRequested = true + clearTimeout(pending.requestTimer) + pending.cancelTimer = setTimeout( + () => this.fail(new Error("TDS cancellation timeout")), + this.config.cancelTimeoutMs ?? 5000 + ) + this.write(Packet.encode(Packet.ATTENTION, Buffer.alloc(0), this.packetSize)) + } + + private write(data: Buffer): void { + try { + ;(this.tls ?? this.socket).write(data) + } catch (error) { + this.fail(error) + } + } + + private fail(cause: unknown): void { + if (this.closed) return + const connecting = this.state !== "ready" + this.state = "closed" + clearTimeout(this.connectTimer) + this.tls?.destroy() + this.bridge?.destroy() + this.socket.destroy() + if (connecting) this.connected(Effect.fail(cause instanceof SqlError ? cause : failure(cause, true))) + const pending = this.pending + this.pending = undefined + if (pending) { + clearTimeout(pending.requestTimer) + clearTimeout(pending.cancelTimer) + pending.resume(Effect.fail(cause instanceof SqlError ? cause : failure(cause, true))) + pending.cancelResume?.() + } + for (const listener of this.closedListeners) listener() + this.closedListeners.clear() + } + + private onPacket(packet: Packet.Packet): void { + if (this.state === "handshake") { + if (packet.type !== Packet.RESPONSE && packet.type !== Packet.PRELOGIN) { + throw new Packet.ProtocolError("Unexpected TLS packet type") + } + this.bridge!.push(packet.data) + return + } + if (packet.type !== Packet.RESPONSE) throw new Packet.ProtocolError("Expected TDS response packet") + if (this.state === "prelogin") { + const message = this.messages.push(packet) + if (!message) return + const encryption = Packet.preloginEncryption(message) + if (encryption === 1 || encryption === 3) this.startTls() + else if (this.config.encrypt !== false) throw new Packet.ProtocolError("Server refused required encryption") + else if (encryption === 2) this.sendLogin() + else throw new Packet.ProtocolError("Server requested unsupported login-only encryption") + return + } + if (this.state !== "login" && this.state !== "ready") return + this.tokens.push(packet.data, (token) => this.onToken(token)) + if (!(packet.status & 1)) return + this.tokens.end() + if (this.state === "login") { + if (this.route) { + this.fail(this.route) + return + } + if (this.ntlmChallenge) { + const response = Ntlm.authenticate(this.ntlmChallenge, { + username: this.config.username ?? "", + password: this.config.password ?? "", + domain: this.config.domain! + }) + this.ntlmChallenge = undefined + this.ntlmSent = true + this.write(Packet.encode(0x11, response, this.packetSize)) + response.fill(0) + return + } + if (this.loginError) { + this.fail(this.loginError) + return + } + if (!this.loginAck || !this.loginDone) throw new Packet.ProtocolError("Incomplete LOGIN7 response") + this.state = "ready" + clearTimeout(this.connectTimer) + this.connected(Effect.succeed(this)) + return + } + const pending = this.pending + if (!pending) throw new Packet.ProtocolError("Unsolicited TDS response") + if (!pending.done && !pending.attention) throw new Packet.ProtocolError("Response ended without final DONE") + if (pending.cancelRequested && !pending.attention) return // normal completion raced with ATTENTION + this.pending = undefined + clearTimeout(pending.requestTimer) + clearTimeout(pending.cancelTimer) + pending.resume( + pending.error ? Effect.fail(pending.error) : Effect.succeed({ + rows: pending.rows, + output: pending.output, + rowCount: pending.rowCount, + returnStatus: pending.returnStatus + }) + ) + pending.cancelResume?.() + } + + private startTls(): void { + this.state = "handshake" + this.bridge = new Duplex({ + read() {}, + write: (chunk: Buffer, _encoding, callback) => { + this.socket.write( + this.state === "handshake" ? Packet.encode(Packet.PRELOGIN, chunk, this.packetSize) : chunk, + callback + ) + } + }) + this.bridge.on("error", (error) => this.fail(error)) + const tls = this.tls = Tls.connect({ + socket: this.bridge, + servername: Net.isIP(this.config.server) ? undefined : this.config.server, + rejectUnauthorized: !(this.config.trustServer ?? false), + checkServerIdentity: (_hostname, cert) => Tls.checkServerIdentity(this.config.server, cert) + }) + tls.setMaxSendFragment(Math.min(this.packetSize, 16384)) + tls.on("error", (error) => this.fail(error)) + const packets = new Packet.PacketParser() + tls.on("data", (chunk: Buffer) => { + try { + packets.push(chunk, (packet) => this.onPacket(packet)) + } catch (error) { + this.fail(error) + } + }) + tls.once("secureConnect", () => this.sendLogin()) + } + + private sendLogin(): void { + this.state = "login" + this.write(Packet.encode(Packet.LOGIN7, this.loginPayload, this.packetSize)) + this.loginPayload.fill(0) + } + + private onToken(token: Token): void { + const pending = this.pending + switch (token._tag) { + case "LoginAck": + if (token.version !== 0x74000004) throw new Packet.ProtocolError("Server did not negotiate TDS 7.4") + this.loginAck = true + break + case "Error": { + const error = Object.assign(new Error(token.error.message), token.error, { code: "EREQUEST" }) + if (this.state === "login") this.loginError = failure(error, true) + else if (pending) pending.error ??= failure(error) + if (token.error.class >= 20) this.fail(error) + break + } + case "Done": + if (this.state === "login") this.loginDone = !(token.status & 1) + else if (pending) { + pending.done = !(token.status & 1) && token.kind !== 0xff + pending.attention ||= !!(token.status & 0x20) + if (token.status & 0x10) pending.rowCount += Number(token.rowCount) + if ((token.status & 0x102) && !pending.error) { + pending.error = failure(new Error("SQL Server reported a failed statement")) + } + } + break + case "Row": + if (!pending) throw new Packet.ProtocolError("Row without active request") + if (pending.cancelRequested) break + if (pending.values) pending.rows.push(token.values) + else { + const row: Record = {} + const columns = this.tokens.columns! + for (let i = 0; i < columns.length; i++) { + const name = columns[i].name + if (name !== "__proto__") { + row[name] = token.values[i] + continue + } + Object.defineProperty(row, name, { + value: token.values[i], + writable: true, + configurable: true, + enumerable: true + }) + } + pending.rows.push(row) + } + break + case "ReturnValue": + if (pending) { + Object.defineProperty(pending.output, token.name, { + value: token.value, + enumerable: true, + configurable: true, + writable: true + }) + } + break + case "ReturnStatus": + if (pending) pending.returnStatus = token.value + break + case "EnvChange": + this.environment(token.data) + break + case "Sspi": + if (this.config.authType !== "ntlm" || this.state !== "login" || this.ntlmSent || this.ntlmChallenge) { + throw new Packet.ProtocolError("Unexpected SSPI challenge") + } + this.ntlmChallenge = token.data + break + } + } + + private environment(data: Buffer): void { + const r = new Reader(data, true) + const type = r.u8() + if (type === 4) { + const size = Number(r.bString()) + if (!Number.isInteger(size) || size < 512 || size > 32767) { + throw new Packet.ProtocolError("Invalid negotiated packet size") + } + this.packetSize = size + this.tls?.setMaxSendFragment(Math.min(size, 16384)) + } else if (type === 7) { + const collation = r.take(r.u8()) + if (collation.length !== 5) throw new Packet.ProtocolError("Invalid negotiated collation") + this.collation = Buffer.from(collation) + } else if (type === 8) { + const transaction = r.take(r.u8()) + if (transaction.length !== 8) throw new Packet.ProtocolError("Invalid transaction descriptor") + this.transaction = Buffer.from(transaction) + } else if (type === 9 || type === 10 || type === 17) { + this.transaction = Buffer.alloc(8) + } else if (type === 20) { + if (this.state !== "login" || this.route) throw new Packet.ProtocolError("Unexpected SQL Server routing") + const route = new Reader(r.take(r.u16()), true) + if (route.u8() !== 0) throw new Packet.ProtocolError("Unsupported routing protocol") + const port = route.u16() + const server = route.usString() + if (port === 0 || !server || server.includes("\0") || route.offset !== route.data.length) { + throw new Packet.ProtocolError("Invalid routing target") + } + this.route = new RoutingChange(server, port) + } + } +} + +const connect = (config: Config): Effect.Effect => + Effect.callback((resume) => { + let session: Session + try { + session = new Session(config, resume) + } catch (cause) { + resume(Effect.fail(failure(cause, true))) + return + } + return Effect.sync(() => session.close()) + }) + +export const instancePort = ( + server: string, + instance: string, + timeout: number, + browserPort = 1434 +): Effect.Effect => + Effect.callback((resume) => { + if (!/^[\x20-\x7e]{1,128}$/.test(instance) || instance.includes(";")) { + resume(Effect.fail(failure(new Error("Invalid instance name"), true))) + return + } + const socket = Dgram.createSocket(Net.isIPv6(server) ? "udp6" : "udp4") + let finished = false + const complete = (result: Effect.Effect) => { + if (finished) return + finished = true + clearTimeout(timer) + socket.close() + resume(result) + } + const timer = setTimeout( + () => complete(Effect.fail(failure(new Error("SQL Browser lookup timed out"), true))), + timeout + ) + socket.on("error", (cause) => complete(Effect.fail(failure(cause, true)))) + socket.on("message", (message) => { + try { + if (message.length < 3 || message[0] !== 5 || message.readUInt16LE(1) !== message.length - 3) { + throw new Packet.ProtocolError("Invalid SQL Browser response") + } + const records = message.toString("ascii", 3).split(";;") + for (const record of records) { + const parts = record.split(";") + const fields = new Map() + for (let i = 0; i + 1 < parts.length; i += 2) fields.set(parts[i].toLowerCase(), parts[i + 1]) + if (fields.get("instancename")?.toLowerCase() !== instance.toLowerCase()) continue + const port = Number(fields.get("tcp")) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Packet.ProtocolError("Invalid instance TCP port") + } + complete(Effect.succeed(port)) + return + } + throw new Packet.ProtocolError("Instance not present in SQL Browser response") + } catch (cause) { + complete(Effect.fail(failure(cause, true))) + } + }) + socket.connect(browserPort, server, () => { + if (finished) return + socket.send(Buffer.concat([Buffer.from([4]), Buffer.from(instance, "ascii"), Buffer.from([0])]), (cause) => { + if (cause) complete(Effect.fail(failure(cause, true))) + }) + }) + return Effect.sync(() => { + if (!finished) { + finished = true + clearTimeout(timer) + socket.close() + } + }) + }) + +export const make = (config: Config): Effect.Effect => + Effect.gen(function*() { + const deadline = Date.now() + (config.connectTimeoutMs ?? 15000) + const session = yield* Effect.acquireRelease( + Effect.suspend(() => { + const deadline = Date.now() + (config.connectTimeoutMs ?? 15000) + const maxRetries = config.maxRetriesOnTransientErrors ?? 3 + const retryInterval = config.connectionRetryIntervalMs ?? 500 + if (!Number.isInteger(maxRetries) || maxRetries < 0 || !Number.isFinite(retryInterval) || retryInterval <= 0) { + return Effect.fail(failure(new Packet.ProtocolError("Invalid connection retry options"), true)) + } + const attempt = (config: Config, redirects: number, retries = 0): Effect.Effect => + Effect.suspend(() => { + const remaining = deadline - Date.now() + if (remaining <= 0) return Effect.fail(failure(new Error("TDS connection timeout"), true)) + return connect({ ...config, connectTimeoutMs: remaining }).pipe(Effect.catch((error) => { + const cause = error.reason.cause + if (cause instanceof RoutingChange && redirects < 5) { + return attempt({ ...config, server: cause.server, port: cause.port }, redirects + 1, retries) + } + const number = typeof cause === "object" && cause !== null && "number" in cause ? cause.number : undefined + if ( + typeof number === "number" && [4060, 10928, 10929, 40197, 40501, 40613].includes(number) && + retries < maxRetries + ) { + return Effect.flatMap( + Effect.callback((resume) => { + const timer = setTimeout( + () => resume(Effect.void), + Math.min(retryInterval, Math.max(1, deadline - Date.now())) + ) + return Effect.sync(() => clearTimeout(timer)) + }), + () => attempt(config, redirects, retries + 1) + ) + } + return Effect.fail(error) + })) + }) + return config.instanceName && config.port === undefined ? + Effect.flatMap( + instancePort(config.server, config.instanceName, config.connectTimeoutMs ?? 15000), + (port) => attempt({ ...config, port }, 0) + ) : + attempt(config, 0) + }), + (session) => Effect.sync(() => session.close()) + ) + if (config.initializeSession !== false) yield* session.batch(initialSql, Math.max(1, deadline - Date.now())) + return session + }) + +const initialSql = `SET ANSI_NULLS ON +SET ANSI_NULL_DFLT_ON ON +SET ANSI_PADDING ON +SET ANSI_WARNINGS ON +SET ARITHABORT ON +SET CONCAT_NULL_YIELDS_NULL ON +SET IMPLICIT_TRANSACTIONS OFF +SET NUMERIC_ROUNDABORT OFF +SET QUOTED_IDENTIFIER ON +SET TEXTSIZE 2147483647 +SET TRANSACTION ISOLATION LEVEL READ COMMITTED +SET XACT_ABORT OFF +SET LANGUAGE us_english +SET DATEFORMAT mdy +SET DATEFIRST 7` diff --git a/packages/sql/mssql/src/internal/tdsNtlm.ts b/packages/sql/mssql/src/internal/tdsNtlm.ts new file mode 100644 index 00000000000..ef68dcd55d2 --- /dev/null +++ b/packages/sql/mssql/src/internal/tdsNtlm.ts @@ -0,0 +1,148 @@ +import md4 from "js-md4" +import { Buffer } from "node:buffer" +import { createHmac, randomBytes } from "node:crypto" +import { ProtocolError } from "./tdsPacket.ts" + +const signature = Buffer.from("NTLMSSP\0", "ascii") +const flags = 0xa2888205 // Unicode, NTLM, target info, extended security, version, 128/56 +const version = Buffer.from([10, 0, 0, 0, 0, 0, 0, 15]) +const hmac = (key: Buffer, data: Buffer): Buffer => createHmac("md5", key).update(data).digest() + +export const responseKey = (username: string, domain: string, password: string): Buffer => + hmac( + Buffer.from(md4.arrayBuffer(Buffer.from(password, "utf16le"))), + Buffer.from(username.toUpperCase() + domain, "utf16le") + ) + +export const negotiate = (): Buffer => { + const out = Buffer.alloc(40) + signature.copy(out) + out.writeUInt32LE(1, 8) + out.writeUInt32LE(flags, 12) + out.writeUInt32LE(40, 20) + out.writeUInt32LE(40, 28) + version.copy(out, 32) + return out +} + +const field = (data: Buffer, offset: number): Buffer => { + if (offset + 8 > data.length) throw new ProtocolError("Truncated NTLM security buffer") + const length = data.readUInt16LE(offset) + const start = data.readUInt32LE(offset + 4) + if (data.readUInt16LE(offset + 2) < length || start < 48 || start + length > data.length) { + throw new ProtocolError("Invalid NTLM security buffer") + } + return data.subarray(start, start + length) +} + +export interface Credentials { + readonly username: string + readonly password: string + readonly domain: string +} + +/** MS-NLMP 3.3.2. Optional time/nonce are only for deterministic protocol tests. */ +export const authenticate = ( + challenge: Buffer, + credentials: Credentials, + options: { readonly nonce?: Buffer; readonly timestamp?: Buffer; readonly negotiate?: Buffer } = {} +): Buffer => { + if (challenge.length < 48 || !challenge.subarray(0, 8).equals(signature) || challenge.readUInt32LE(8) !== 2) { + throw new ProtocolError("Invalid NTLM challenge") + } + const serverFlags = challenge.readUInt32LE(20) + if (!(serverFlags & 1) || !(serverFlags & 0x80000) || !(serverFlags & 0x800000)) { + throw new ProtocolError("Server does not support NTLMv2 target information") + } + const target = field(challenge, 40) + const pairs: Array = [] + let timestamp = options.timestamp + let serverTimestamp = false + let avFlags: number | undefined + let ended = false + for (let offset = 0; offset < target.length;) { + if (offset + 4 > target.length) throw new ProtocolError("Truncated NTLM target info") + const id = target.readUInt16LE(offset) + const length = target.readUInt16LE(offset + 2) + if (offset + 4 + length > target.length) throw new ProtocolError("Invalid NTLM target info length") + if (id === 0) { + if (length !== 0 || offset + 4 !== target.length) throw new ProtocolError("Invalid NTLM target terminator") + ended = true + break + } + const value = target.subarray(offset + 4, offset + 4 + length) + if (id === 7) { + if (length !== 8 || serverTimestamp) throw new ProtocolError("Invalid NTLM server timestamp") + timestamp = Buffer.from(value) + serverTimestamp = true + } + if (id === 6) { + if (length !== 4 || avFlags !== undefined) throw new ProtocolError("Invalid NTLM flags") + avFlags = value.readUInt32LE(0) + } else pairs.push(Buffer.from(target.subarray(offset, offset + 4 + length))) + offset += 4 + length + } + if (!ended) throw new ProtocolError("Missing NTLM target terminator") + const mic = serverTimestamp || !!((avFlags ?? 0) & 2) + if (avFlags !== undefined || mic) { + const pair = Buffer.alloc(8) + pair.writeUInt16LE(6) + pair.writeUInt16LE(4, 2) + pair.writeUInt32LE((avFlags ?? 0) | (mic ? 2 : 0), 4) + pairs.push(pair) + } + pairs.push(Buffer.alloc(4)) + const targetInfo = Buffer.concat(pairs) + const nonce = options.nonce ?? randomBytes(8) + if (nonce.length !== 8 || (timestamp && timestamp.length !== 8)) { + throw new ProtocolError("Invalid NTLM nonce or timestamp") + } + if (!timestamp) { + timestamp = Buffer.alloc(8) + timestamp.writeBigUInt64LE((BigInt(Date.now()) + BigInt("11644473600000")) * BigInt("10000")) + } + const blob = Buffer.alloc(28 + targetInfo.length + 4) + blob[0] = blob[1] = 1 + timestamp.copy(blob, 8) + nonce.copy(blob, 16) + targetInfo.copy(blob, 28) + const key = responseKey(credentials.username, credentials.domain, credentials.password) + const serverNonce = challenge.subarray(24, 32) + const proof = hmac(key, Buffer.concat([serverNonce, blob])) + const ntResponse = Buffer.concat([proof, blob]) + const lmResponse = serverTimestamp + ? Buffer.alloc(24) + : Buffer.concat([hmac(key, Buffer.concat([serverNonce, nonce])), nonce]) + const domain = Buffer.from(credentials.domain, "utf16le") + const username = Buffer.from(credentials.username, "utf16le") + const hasVersion = !!(serverFlags & 0x02000000) + const header = Buffer.alloc(64 + (hasVersion ? 8 : 0) + (mic ? 16 : 0)) + signature.copy(header) + header.writeUInt32LE(3, 8) + header.writeUInt32LE((flags & serverFlags) >>> 0, 60) + if (hasVersion) version.copy(header, 64) + const parts = [header] + let position = header.length + for ( + const [offset, data] of [[12, lmResponse], [20, ntResponse], [28, domain], [36, username], [44, Buffer.alloc(0)], [ + 52, + Buffer.alloc(0) + ]] as const + ) { + if (data.length > 65535) throw new ProtocolError("NTLM field too long") + header.writeUInt16LE(data.length, offset) + header.writeUInt16LE(data.length, offset + 2) + header.writeUInt32LE(position, offset + 4) + parts.push(data) + position += data.length + } + const output = Buffer.concat(parts) + if (mic) { + hmac(hmac(key, proof), Buffer.concat([options.negotiate ?? negotiate(), challenge, output])).copy( + output, + 64 + (hasVersion ? 8 : 0) + ) + } + key.fill(0) + return output +} diff --git a/packages/sql/mssql/src/internal/tdsPacket.ts b/packages/sql/mssql/src/internal/tdsPacket.ts new file mode 100644 index 00000000000..1333dd37ff0 --- /dev/null +++ b/packages/sql/mssql/src/internal/tdsPacket.ts @@ -0,0 +1,250 @@ +import { Buffer } from "node:buffer" + +/** MS-TDS 2.2.3: packet headers use network byte order. */ +export const HEADER_SIZE = 8 +export const SQL_BATCH = 0x01 +export const RPC = 0x03 +export const RESPONSE = 0x04 +export const ATTENTION = 0x06 +export const LOGIN7 = 0x10 +export const PRELOGIN = 0x12 + +export class ProtocolError extends Error { + readonly code = "EPROTOCOL" + constructor(message: string) { + super(message) + this.name = "TdsProtocolError" + } +} + +export interface Packet { + readonly type: number + readonly status: number + readonly data: Buffer +} + +/** The callback runs synchronously; it may change the session's protocol state. */ +export class PacketParser { + private readonly header = Buffer.alloc(HEADER_SIZE) + private headerBytes = 0 + private body: Buffer | undefined + private bodyBytes = 0 + private bodySize = 0 + private type = 0 + private status = 0 + + push(chunk: Buffer, consume: (packet: Packet) => void): void { + let offset = 0 + while (offset < chunk.length) { + if (this.headerBytes < HEADER_SIZE) { + const size = Math.min(HEADER_SIZE - this.headerBytes, chunk.length - offset) + chunk.copy(this.header, this.headerBytes, offset, offset + size) + offset += size + this.headerBytes += size + if (this.headerBytes < HEADER_SIZE) return + const length = this.header.readUInt16BE(2) + if (length < HEADER_SIZE) throw new ProtocolError(`Invalid TDS packet length ${length}`) + this.type = this.header[0] + this.status = this.header[1] + this.bodySize = length - HEADER_SIZE + this.bodyBytes = 0 + } + const remaining = this.bodySize - this.bodyBytes + if (this.body === undefined && chunk.length - offset >= remaining) { + const data = chunk.subarray(offset, offset + remaining) + offset += remaining + this.headerBytes = 0 + consume({ type: this.type, status: this.status, data }) + } else { + this.body ??= Buffer.allocUnsafe(this.bodySize) + const size = Math.min(remaining, chunk.length - offset) + chunk.copy(this.body, this.bodyBytes, offset, offset + size) + this.bodyBytes += size + offset += size + if (this.bodyBytes < this.bodySize) return + const data = this.body + this.body = undefined + this.headerBytes = 0 + consume({ type: this.type, status: this.status, data }) + } + } + } + + end(): void { + if (this.headerBytes !== 0 || this.body !== undefined) { + throw new ProtocolError("Connection ended inside a TDS packet") + } + } +} + +/** Encode a complete message in one allocation, including an empty ATTENTION. */ +export const encode = (type: number, data: Buffer, packetSize = 4096): Buffer => { + if (!Number.isInteger(packetSize) || packetSize < 512 || packetSize > 32767) { + throw new ProtocolError("TDS packet size must be an integer between 512 and 32767") + } + const capacity = packetSize - HEADER_SIZE + const count = Math.max(1, Math.ceil(data.length / capacity)) + const output = Buffer.allocUnsafe(data.length + count * HEADER_SIZE) + let source = 0 + let offset = 0 + for (let i = 0; i < count; i++) { + const size = Math.min(capacity, data.length - source) + output[offset] = type + output[offset + 1] = i === count - 1 ? 1 : 0 + output.writeUInt16BE(size + HEADER_SIZE, offset + 2) + output.writeUInt16BE(0, offset + 4) + output[offset + 6] = (i + 1) & 0xff + output[offset + 7] = 0 + data.copy(output, offset + HEADER_SIZE, source, source + size) + source += size + offset += size + HEADER_SIZE + } + return output +} + +/** Bounded assembly for startup messages; query rows use the token parser. */ +export class MessageParser { + private parts: Array = [] + private size = 0 + private type: number | undefined + + readonly maxMessageSize: number + constructor(maxMessageSize = 16 * 1024 * 1024) { + this.maxMessageSize = maxMessageSize + } + + push(packet: Packet): Buffer | undefined { + if (this.type !== undefined && this.type !== packet.type) { + throw new ProtocolError("TDS packet type changed inside a message") + } + this.type = packet.type + this.size += packet.data.length + if (this.size > this.maxMessageSize) throw new ProtocolError("TDS message exceeds configured size limit") + this.parts.push(packet.data) + if ((packet.status & 1) === 0) return undefined + const data = this.parts.length === 1 ? this.parts[0] : Buffer.concat(this.parts, this.size) + this.parts = [] + this.size = 0 + this.type = undefined + return data + } + + end(): void { + if (this.type !== undefined) throw new ProtocolError("Connection ended inside a TDS message") + } +} + +/** MS-TDS 2.2.6.5: VERSION, ENCRYPTION, INSTOPT, THREADID, MARS. */ +export const prelogin = (encrypt: boolean): Buffer => { + const entries = [ + Buffer.from([0, 0, 0, 0, 0, 0]), + Buffer.from([encrypt ? 1 : 2]), + Buffer.from([0]), + Buffer.alloc(4), + Buffer.from([0]) + ] + const header = Buffer.alloc(entries.length * 5 + 1) + let offset = header.length + for (let i = 0; i < entries.length; i++) { + header[i * 5] = i + header.writeUInt16BE(offset, i * 5 + 1) + header.writeUInt16BE(entries[i].length, i * 5 + 3) + offset += entries[i].length + } + header[header.length - 1] = 0xff + return Buffer.concat([header, ...entries]) +} + +export const preloginEncryption = (data: Buffer): number => { + let encryption: number | undefined + let offset = 0 + const ranges: Array = [] + while (offset < data.length && data[offset] !== 0xff) { + if (offset + 5 > data.length) throw new ProtocolError("Truncated PRELOGIN option") + const start = data.readUInt16BE(offset + 1) + const size = data.readUInt16BE(offset + 3) + if (start + size > data.length) throw new ProtocolError("PRELOGIN option outside message") + ranges.push([start, size]) + if (data[offset] === 1) { + if (size !== 1 || encryption !== undefined) throw new ProtocolError("Invalid PRELOGIN encryption option") + encryption = data[start] + } + offset += 5 + } + if (offset >= data.length) throw new ProtocolError("Missing PRELOGIN terminator") + for (const [start] of ranges) { + if (start <= offset) throw new ProtocolError("PRELOGIN option overlaps header") + } + if (encryption === undefined || encryption > 3) throw new ProtocolError("Missing or invalid PRELOGIN encryption") + return encryption +} + +export interface LoginOptions { + readonly server: string + readonly username?: string | undefined + readonly password?: string | undefined + readonly database?: string | undefined + readonly applicationName?: string | undefined + readonly packetSize?: number | undefined + readonly sspi?: Buffer | undefined +} + +/** SQL authentication LOGIN7, TDS 7.4, without optional feature extensions. */ +export const login = (options: LoginOptions): Buffer => { + const header = Buffer.alloc(94) + header.writeUInt32LE(0x74000004, 4) + header.writeUInt32LE(options.packetSize ?? 4096, 8) + header.writeUInt32LE(process.pid, 16) + header[24] = 0xe0 // little endian, ASCII, IEEE, database notification, fatal database error + header[25] = 0x03 // fatal language error and ODBC session semantics + if (options.sspi) header[25] |= 0x80 + header[27] = 0x08 // unknown collation handling + const fields = [ + [36, "effect"], + [40, options.sspi ? "" : options.username ?? ""], + [44, options.sspi ? "" : options.password ?? ""], + [48, options.applicationName ?? "@effect/sql-mssql"], + [52, options.server], + [56, ""], + [60, "Effect"], + [64, ""], + [68, options.database ?? "master"], + [78, ""], + [82, ""], + [86, ""] + ] as const + const parts: Array = [header] + let offset = header.length + for (const [position, value] of fields) { + if (value.length > 128) throw new ProtocolError("LOGIN7 field exceeds 128 UTF-16 code units") + const data = Buffer.from(value, "utf16le") + if (position === 44) { + for (let i = 0; i < data.length; i++) data[i] = ((data[i] << 4) | (data[i] >>> 4)) ^ 0xa5 + } + header.writeUInt16LE(offset, position) + header.writeUInt16LE(value.length, position + 2) + parts.push(data) + offset += data.length + } + if (options.sspi) { + if (options.sspi.length > 65535) throw new ProtocolError("SSPI payload too long") + header.writeUInt16LE(offset, 78) + header.writeUInt16LE(options.sspi.length, 80) + parts.push(options.sspi) + offset += options.sspi.length + } + header.writeUInt32LE(offset, 0) + return Buffer.concat(parts, offset) +} + +/** MS-TDS 2.2.5.3: transaction descriptor ALL_HEADERS. */ +export const allHeaders = (transaction: Buffer): Buffer => { + if (transaction.length !== 8) throw new ProtocolError("Invalid transaction descriptor") + const header = Buffer.alloc(22) + header.writeUInt32LE(22, 0) + header.writeUInt32LE(18, 4) + header.writeUInt16LE(2, 8) + transaction.copy(header, 10) + header.writeUInt32LE(1, 18) + return header +} diff --git a/packages/sql/mssql/src/internal/tdsRequest.ts b/packages/sql/mssql/src/internal/tdsRequest.ts new file mode 100644 index 00000000000..efb5e221335 --- /dev/null +++ b/packages/sql/mssql/src/internal/tdsRequest.ts @@ -0,0 +1,442 @@ +import * as Iconv from "iconv-lite" +import { Buffer } from "node:buffer" +import { encoding } from "./tdsCollation.ts" +import { allHeaders, ProtocolError } from "./tdsPacket.ts" + +export interface ParameterOptions { + readonly length?: number | undefined + readonly precision?: number | undefined + readonly scale?: number | undefined +} + +export interface DataType { + readonly name: string + readonly id: number + readonly validate: (value: unknown, collation?: unknown) => unknown +} + +const type = (name: string, id: number): DataType => ({ name, id, validate: (value) => value ?? null }) + +export const TYPES = { + TinyInt: type("TinyInt", 0x30), + SmallInt: type("SmallInt", 0x34), + Int: type("Int", 0x38), + BigInt: type("BigInt", 0x7f), + Bit: type("Bit", 0x32), + Real: type("Real", 0x3b), + Float: type("Float", 0x3e), + NVarChar: type("NVarChar", 0xe7), + NChar: type("NChar", 0xef), + VarChar: type("VarChar", 0xa7), + Char: type("Char", 0xaf), + VarBinary: type("VarBinary", 0xa5), + Binary: type("Binary", 0xad), + Date: type("Date", 0x28), + Time: type("Time", 0x29), + DateTime: type("DateTime", 0x3d), + DateTime2: type("DateTime2", 0x2a), + DateTimeOffset: type("DateTimeOffset", 0x2b), + SmallDateTime: type("SmallDateTime", 0x3a), + UniqueIdentifier: type("UniqueIdentifier", 0x24), + Decimal: type("Decimal", 0x6a), + Numeric: type("Numeric", 0x6c), + Money: type("Money", 0x3c), + SmallMoney: type("SmallMoney", 0x7a), + Text: type("Text", 0x23), + NText: type("NText", 0x63), + Image: type("Image", 0x22), + Xml: type("Xml", 0xf1), + TVP: type("TVP", 0xf3), + UDT: type("UDT", 0xf0), + Variant: type("Variant", 0x62) +} as const + +export interface Table { + readonly name: string + readonly schema?: string | undefined + readonly columns: ReadonlyArray + readonly rows: ReadonlyArray> +} + +const table = (value: unknown): Table => { + if ( + typeof value !== "object" || value === null || !("columns" in value) || !("rows" in value) || + !Array.isArray(value.columns) || !Array.isArray(value.rows) || !("name" in value) || typeof value.name !== "string" + ) { + throw new ProtocolError("Expected a named table-valued parameter with columns and rows") + } + if (value.columns.length > 1024) throw new ProtocolError("TVP exceeds column limit") + return value as unknown as Table +} + +const identifier = (name: string): string => { + if (name.length === 0 || name.length > 128 || name.includes("\0")) { + throw new ProtocolError("Invalid SQL type identifier") + } + return `[${name.replaceAll("]", "]]")}]` +} + +export interface Parameter { + readonly name: string + readonly type: DataType + readonly value: unknown + readonly options?: ParameterOptions | undefined + readonly output?: boolean | undefined +} + +const u16 = (n: number): Buffer => { + const b = Buffer.allocUnsafe(2) + b.writeUInt16LE(n) + return b +} + +const integer = (value: unknown, min: number, max: number): number => { + if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) { + throw new ProtocolError(`Expected integer between ${min} and ${max}`) + } + return value +} + +const number = (value: unknown): number => { + if (typeof value !== "number" || !Number.isFinite(value)) throw new ProtocolError("Expected a finite number") + return value +} + +const date = (value: unknown): Date => { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new ProtocolError("Expected a valid Date") + return value +} + +export const declaration = (p: Parameter): string => { + const name = p.type.name.toLowerCase() + const options = p.options ?? {} + if (name === "tvp") { + const t = table(p.value) + return `${identifier(t.schema ?? "dbo")}.${identifier(t.name)} READONLY` + } + if (["nvarchar", "nchar", "varchar", "char", "varbinary", "binary"].includes(name)) { + const unicode = name.startsWith("n") + const limit = unicode ? 4000 : 8000 + const valueLength = typeof p.value === "string" ? + (unicode ? p.value.length : Buffer.byteLength(p.value)) : + p.value instanceof Uint8Array + ? p.value.byteLength + : 1 + const length = options.length ?? (p.value == null ? limit : Math.max(1, valueLength)) + if (!(length === Infinity || Number.isInteger(length) && length > 0)) { + throw new ProtocolError("Invalid parameter length") + } + if ((name === "nchar" || name === "char" || name === "binary") && length > limit) { + throw new ProtocolError("Fixed parameter length exceeds type limit") + } + return `${name}(${length > limit ? "max" : length})` + } + if (name === "decimal" || name === "numeric") { + const precision = options.precision ?? 18 + const scale = options.scale ?? 0 + integer(precision, 1, 38) + integer(scale, 0, precision) + return `${name}(${precision},${scale})` + } + if (["time", "datetime2", "datetimeoffset"].includes(name)) { + return `${name}(${integer(options.scale ?? 7, 0, 7)})` + } + if (!Object.values(TYPES).some((t) => t.name === p.type.name)) { + throw new ProtocolError(`Unsupported parameter type ${p.type.name}`) + } + return name +} + +const encodeValue = (p: Parameter, collation: Buffer): { info: Buffer; body: Buffer } => { + const declared = declaration(p) + const value = p.value + const isNull = value === null || value === undefined + const name = p.type.name + let info: Buffer + let data: Buffer = Buffer.alloc(0) + let prefix: Buffer | undefined + if (name === "TVP") { + if (p.output) throw new ProtocolError("TVPs cannot be output parameters") + const t = table(value) + const bString = (s: string) => { + if (s.length > 128) throw new ProtocolError("TVP type name too long") + return Buffer.concat([Buffer.from([s.length]), Buffer.from(s, "utf16le")]) + } + info = Buffer.concat([Buffer.from([0xf3, 0]), bString(t.schema ?? "dbo"), bString(t.name)]) + const columns = t.columns.map((c) => { + if (["TVP", "Text", "NText", "Image"].includes(c.type.name)) { + throw new ProtocolError("Unsupported TVP column type") + } + const length = c.length ?? (c.type.name.startsWith("N") ? 4000 : 8000) + const p: Parameter = { name: c.name, type: c.type, value: null, options: { ...c, length } } + return { parameter: p, info: encodeValue(p, collation).info } + }) + const parts = [u16(columns.length)] + for (const c of columns) parts.push(Buffer.alloc(6), c.info, Buffer.from([0])) + parts.push(Buffer.from([0])) + for (const row of t.rows) { + if (!Array.isArray(row) || row.length !== columns.length) { + throw new ProtocolError("TVP row does not match columns") + } + parts.push(Buffer.from([1])) + for (let i = 0; i < columns.length; i++) { + parts.push(encodeValue({ ...columns[i].parameter, value: row[i] }, collation).body) + } + } + parts.push(Buffer.from([0])) + return { info, body: Buffer.concat(parts) } + } else if (["Text", "NText", "Image", "Xml"].includes(name)) { + if (!isNull) { + if (name === "Image") { + if (!(value instanceof Uint8Array)) throw new ProtocolError("Expected Uint8Array") + data = Buffer.from(value.buffer, value.byteOffset, value.byteLength) + } else { + if (typeof value !== "string") throw new ProtocolError("Expected string") + const codepage = name === "Text" ? encoding(collation) : "utf16le" + if (!codepage) throw new ProtocolError("Unsupported SQL Server collation") + data = Iconv.encode(value, codepage) + } + } + if (name === "Xml") { + info = Buffer.from([0xf1, 0]) + const total = Buffer.alloc(8, isNull ? 0xff : 0) + if (!isNull) total.writeBigUInt64LE(BigInt(data.length)) + const chunk = Buffer.alloc(4) + chunk.writeUInt32LE(data.length) + return { + info, + body: isNull ? total : Buffer.concat([total, chunk, data, ...(data.length > 0 ? [Buffer.alloc(4)] : [])]) + } + } + const length = Buffer.alloc(4) + length.writeUInt32LE(isNull ? 0xffffffff : data.length) + info = Buffer.concat([Buffer.from([p.type.id]), length, ...(name === "Image" ? [] : [collation])]) + prefix = length + } else if (["TinyInt", "SmallInt", "Int", "BigInt"].includes(name)) { + const size = name === "TinyInt" ? 1 : name === "SmallInt" ? 2 : name === "Int" ? 4 : 8 + info = Buffer.from([0x26, size]) + if (!isNull) { + data = Buffer.allocUnsafe(size) + if (size === 8) { + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new ProtocolError("BigInt number must be a safe integer") + } + if (typeof value !== "bigint" && typeof value !== "string" && typeof value !== "number") { + throw new ProtocolError("Invalid BigInt") + } + data.writeBigInt64LE(BigInt(value)) + } else if (size === 1) data[0] = integer(value, 0, 255) + else data.writeIntLE(integer(value, -(2 ** (size * 8 - 1)), 2 ** (size * 8 - 1) - 1), 0, size) + } + } else if (name === "Bit") { + info = Buffer.from([0x68, 1]) + if (!isNull) { + if (typeof value !== "boolean" && value !== 0 && value !== 1) throw new ProtocolError("Invalid Bit") + data = Buffer.from([value ? 1 : 0]) + } + } else if (name === "Float" || name === "Real") { + const size = name === "Real" ? 4 : 8 + info = Buffer.from([0x6d, size]) + if (!isNull) { + data = Buffer.allocUnsafe(size) + if (size === 4) data.writeFloatLE(number(value)) + else data.writeDoubleLE(number(value)) + } + } else if (["NVarChar", "NChar", "VarChar", "Char", "VarBinary", "Binary"].includes(name)) { + const unicode = name.startsWith("N") + const binary = name === "VarBinary" || name === "Binary" + if (!isNull) { + if (binary) { + if (!(value instanceof Uint8Array)) throw new ProtocolError("Expected Uint8Array") + data = Buffer.from(value.buffer, value.byteOffset, value.byteLength) + } else { + if (typeof value !== "string") throw new ProtocolError("Expected string") + const codepage = unicode ? "utf16le" : encoding(collation) + if (!codepage) throw new ProtocolError("Unsupported SQL Server parameter collation") + data = Iconv.encode(value, codepage) + } + } + const max = declared.endsWith("(max)") + const length = max ? 0xffff : Number(declared.slice(declared.indexOf("(") + 1, -1)) * (unicode ? 2 : 1) + if (!max && data.length > length) throw new ProtocolError("Parameter exceeds declared length") + info = Buffer.concat([Buffer.from([p.type.id]), u16(length), ...(binary ? [] : [collation])]) + if (max) { + const total = Buffer.alloc(8, isNull ? 0xff : 0) + if (!isNull) total.writeBigUInt64LE(BigInt(data.length)) + if (isNull) prefix = total + else { + const chunk = Buffer.alloc(4) + chunk.writeUInt32LE(data.length) + prefix = Buffer.concat([total, chunk]) + if (data.length > 0) data = Buffer.concat([data, Buffer.alloc(4)]) + } + } else prefix = u16(isNull ? 0xffff : data.length) + } else if (["Date", "Time", "DateTime2", "DateTimeOffset"].includes(name)) { + const scale = p.options?.scale ?? 7 + const timeSize = scale <= 2 ? 3 : scale <= 4 ? 4 : 5 + info = Buffer.from(name === "Date" ? [0x28] : [p.type.id, scale]) + if (!isNull) { + const d = date(value) + if (d.getUTCFullYear() < 1 || d.getUTCFullYear() > 9999) throw new ProtocolError("Date outside SQL Server range") + const days = Math.floor((d.getTime() + 62135596800000) / 86400000) + const time = ((d.getTime() % 86400000) + 86400000) % 86400000 + const parts: Array = [] + if (name !== "Date") { + const t = Buffer.alloc(timeSize) + t.writeUIntLE(Math.floor(time * 10 ** scale / 1000), 0, timeSize) + parts.push(t) + } + if (name !== "Time") { + const d = Buffer.alloc(3) + d.writeUIntLE(days, 0, 3) + parts.push(d) + } + if (name === "DateTimeOffset") parts.push(Buffer.alloc(2)) + data = Buffer.concat(parts) + } + } else if (name === "DateTime" || name === "SmallDateTime") { + const size = name === "DateTime" ? 8 : 4 + info = Buffer.from([0x6f, size]) + if (!isNull) { + const d = date(value) + let days = Math.floor((d.getTime() + 2208988800000) / 86400000) + const time = ((d.getTime() % 86400000) + 86400000) % 86400000 + data = Buffer.alloc(size) + if (size === 8) { + if (d.getUTCFullYear() < 1753 || d.getUTCFullYear() > 9999) { + throw new ProtocolError("DateTime outside SQL Server range") + } + let ticks = Math.round(time * 0.3) + if (ticks === 25920000) { + days++ + ticks = 0 + } + if (new Date(days * 86400000 - 2208988800000).getUTCFullYear() > 9999) { + throw new ProtocolError("Rounded DateTime outside SQL Server range") + } + data.writeInt32LE(days) + data.writeUInt32LE(ticks, 4) + } else { + let minutes = Math.round(time / 60000) + if (minutes === 1440) { + days++ + minutes = 0 + } + integer(days, 0, 65535) + data.writeUInt16LE(days) + data.writeUInt16LE(minutes, 2) + } + } + } else if (name === "Decimal" || name === "Numeric") { + const precision = p.options?.precision ?? 18 + const scale = p.options?.scale ?? 0 + const size = precision <= 9 ? 5 : precision <= 19 ? 9 : precision <= 28 ? 13 : 17 + info = Buffer.from([p.type.id, size, precision, scale]) + if (!isNull) { + const n = scaledInteger(value, scale) + let magnitude = n < BigInt("0") ? -n : n + if (magnitude >= BigInt("10") ** BigInt(precision)) throw new ProtocolError("Decimal exceeds declared precision") + data = Buffer.alloc(size) + data[0] = n < BigInt("0") ? 0 : 1 + for (let i = 1; i < size; i++) { + data[i] = Number(magnitude & BigInt("255")) + magnitude >>= BigInt("8") + } + } + } else if (name === "Money" || name === "SmallMoney") { + const size = name === "Money" ? 8 : 4 + info = Buffer.from([0x6e, size]) + if (!isNull) { + const n = scaledInteger(value, 4) + const limit = BigInt("1") << BigInt(size * 8 - 1) + if (n < -limit || n >= limit) throw new ProtocolError("Money outside SQL Server range") + data = Buffer.alloc(size) + if (size === 4) data.writeInt32LE(Number(n)) + else { + data.writeInt32LE(Number(n >> BigInt("32"))) + data.writeUInt32LE(Number(n & BigInt("4294967295")), 4) + } + } + } else if (name === "UniqueIdentifier") { + info = Buffer.from([0x24, 16]) + if (!isNull) { + if (typeof value !== "string" || !/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value)) { + throw new ProtocolError("Invalid UUID") + } + data = Buffer.from(value.replaceAll("-", ""), "hex") + data.subarray(0, 4).reverse() + data.subarray(4, 6).reverse() + data.subarray(6, 8).reverse() + } + } else { + throw new ProtocolError(`Parameter encoding for ${name} is not implemented`) + } + return { info, body: Buffer.concat([prefix ?? Buffer.from([data.length]), data]) } +} + +export const encodeParameter = (p: Parameter, collation: Buffer): Buffer => { + const parameterName = p.name.startsWith("@") ? p.name : `@${p.name}` + if (parameterName.length > 255 || !/^@[\p{L}\p{N}_@$#]+$/u.test(parameterName)) { + throw new ProtocolError("Invalid RPC parameter name") + } + const { info, body } = encodeValue(p, collation) + return Buffer.concat([ + Buffer.from([parameterName.length]), + Buffer.from(parameterName, "utf16le"), + Buffer.from([p.output ? 1 : 0]), + info, + body + ]) +} + +/** Round decimal text using integer arithmetic, including exponent notation. */ +const scaledInteger = (value: unknown, scale: number): bigint => { + if (typeof value !== "number" && typeof value !== "string" && typeof value !== "bigint") { + throw new ProtocolError("Invalid decimal") + } + const text = String(value) + const match = /^([+-]?)(\d+)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(text) + if (!match || text.length > 1000) throw new ProtocolError("Invalid decimal") + const exponent = Number(match[4] ?? 0) + scale - (match[3]?.length ?? 0) + if (Math.abs(exponent) > 1000) throw new ProtocolError("Decimal exponent outside supported range") + let n = BigInt(match[2] + (match[3] ?? "")) + if (exponent >= 0) n *= BigInt("10") ** BigInt(exponent) + else { + const divisor = BigInt("10") ** BigInt(-exponent) + n = (n + divisor / BigInt("2")) / divisor + } + return match[1] === "-" ? -n : n +} + +export const rpc = ( + procedure: string | number, + parameters: ReadonlyArray, + transaction: Buffer, + collation: Buffer +): Buffer => { + const name = typeof procedure === "number" ? + Buffer.concat([u16(0xffff), u16(procedure)]) : + Buffer.concat([u16(procedure.length), Buffer.from(procedure, "utf16le")]) + return Buffer.concat([allHeaders(transaction), name, u16(0), ...parameters.map((p) => encodeParameter(p, collation))]) +} + +export const sql = ( + query: string, + parameters: ReadonlyArray, + transaction: Buffer, + collation: Buffer +): Buffer => + rpc( + 10, + [ + { name: "stmt", type: TYPES.NVarChar, value: query }, + ...(parameters.length === 0 ? [] : [{ + name: "params", + type: TYPES.NVarChar, + value: parameters.map((p) => `@${p.name} ${declaration(p)}${p.output ? " OUTPUT" : ""}`).join(",") + }]), + ...parameters + ], + transaction, + collation + ) diff --git a/packages/sql/mssql/src/internal/tdsToken.ts b/packages/sql/mssql/src/internal/tdsToken.ts new file mode 100644 index 00000000000..257fb6ba9fc --- /dev/null +++ b/packages/sql/mssql/src/internal/tdsToken.ts @@ -0,0 +1,464 @@ +import * as Iconv from "iconv-lite" +import { Buffer } from "node:buffer" +import { encoding } from "./tdsCollation.ts" +import { ProtocolError } from "./tdsPacket.ts" + +const incomplete = Symbol("incomplete") + +export class Reader { + offset = 0 + readonly data: Buffer + readonly bounded: boolean + constructor(data: Buffer, bounded = false) { + this.data = data + this.bounded = bounded + } + take(size: number): Buffer { + if (size < 0 || !Number.isSafeInteger(size)) throw new ProtocolError("Invalid TDS value length") + if (this.offset + size > this.data.length) { + if (this.bounded) throw new ProtocolError("Malformed length-delimited TDS token") + throw incomplete + } + const result = this.data.subarray(this.offset, this.offset + size) + this.offset += size + return result + } + u8(): number { + return this.take(1)[0] + } + u16(): number { + return this.take(2).readUInt16LE(0) + } + u32(): number { + return this.take(4).readUInt32LE(0) + } + bString(): string { + return this.take(this.u8() * 2).toString("utf16le") + } + usString(): string { + return this.take(this.u16() * 2).toString("utf16le") + } +} + +export interface Column { + readonly name: string + readonly type: number + readonly length: number + readonly scale: number + readonly precision: number + readonly collation?: Buffer | undefined +} + +export interface ServerError { + readonly number: number + readonly state: number + readonly class: number + readonly message: string + readonly serverName: string + readonly procName: string + readonly lineNumber: number +} + +export type Token = + | { readonly _tag: "Metadata"; readonly columns: ReadonlyArray } + | { readonly _tag: "Row"; readonly values: ReadonlyArray } + | { readonly _tag: "Done"; readonly kind: number; readonly status: number; readonly rowCount: bigint } + | { readonly _tag: "Error" | "Info"; readonly error: ServerError } + | { readonly _tag: "EnvChange"; readonly data: Buffer } + | { readonly _tag: "LoginAck"; readonly version: number } + | { readonly _tag: "ReturnStatus"; readonly value: number } + | { readonly _tag: "ReturnValue"; readonly name: string; readonly value: unknown } + | { readonly _tag: "Sspi"; readonly data: Buffer } + | { readonly _tag: "Ignored" } + +const fixedSizes: Readonly> = { + 0x1f: 0, + 0x30: 1, + 0x32: 1, + 0x34: 2, + 0x38: 4, + 0x3a: 4, + 0x3b: 4, + 0x3c: 8, + 0x3d: 8, + 0x3e: 8, + 0x7a: 4, + 0x7f: 8 +} + +export const metadata = (r: Reader): Column => { + r.u32() // user type + const flags = r.u16() + if (flags & 0x0800) throw new ProtocolError("Encrypted column metadata is not supported") + const type = r.u8() + let length = fixedSizes[type] ?? 0 + let scale = 0 + let precision = 0 + let collation: Buffer | undefined + if (type in fixedSizes || type === 0x28) { + // Fixed types and DATE have no additional metadata. + } else if ([0x24, 0x26, 0x68, 0x6d, 0x6e, 0x6f].includes(type)) { + length = r.u8() + } else if (type === 0x6a || type === 0x6c) { + length = r.u8() + precision = r.u8() + scale = r.u8() + if (precision < 1 || precision > 38 || scale > precision) throw new ProtocolError("Invalid decimal metadata") + } else if (type === 0x29 || type === 0x2a || type === 0x2b) { + scale = r.u8() + if (scale > 7) throw new ProtocolError("Invalid time scale") + } else if ([0xa5, 0xad, 0xa7, 0xaf, 0xe7, 0xef].includes(type)) { + length = r.u16() + if ([0xa7, 0xaf, 0xe7, 0xef].includes(type)) collation = Buffer.from(r.take(5)) + } else if ([0x22, 0x23, 0x63].includes(type)) { + length = r.u32() + if (type !== 0x22) collation = Buffer.from(r.take(5)) + } else if (type === 0xf1) { + length = 0xffff + if (r.u8() === 1) { + r.bString() + r.bString() + r.usString() + } + } else if (type === 0xf0) { + length = r.u16() + r.bString() + r.bString() + r.bString() + r.usString() + } else if (type === 0x62) { + length = r.u32() + } else { + throw new ProtocolError(`Unsupported TDS type 0x${type.toString(16)}`) + } + return { name: "", type, length, scale, precision, collation } +} + +const dateEpoch = -62135596800000 +const datetimeEpoch = -2208988800000 + +const characters = (data: Buffer, column: Column): string => { + if ([0xe7, 0xef, 0x63, 0xf1].includes(column.type)) { + if (data.length % 2 !== 0) throw new ProtocolError("Odd UTF-16 value length") + return data.toString("utf16le") + } + const collation = column.collation + if (!collation) throw new ProtocolError("Missing character collation") + const codepage = encoding(collation) + if (!codepage) throw new ProtocolError("Unsupported SQL Server collation") + return Iconv.decode(data, codepage) +} + +const plp = (r: Reader, maxValueSize: number): Buffer | null => { + const length = r.take(8).readBigUInt64LE(0) + if (length === BigInt("18446744073709551615")) return null + const unknown = length === BigInt("18446744073709551614") + if (!unknown && length > BigInt(maxValueSize)) throw new ProtocolError("TDS value exceeds configured size limit") + const chunks: Array = [] + let total = 0 + while (true) { + const size = r.u32() + if (size === 0) break + total += size + if (total > maxValueSize || (!unknown && BigInt(total) > length)) { + throw new ProtocolError("Invalid PLP chunk length") + } + chunks.push(r.take(size)) + } + if (!unknown && BigInt(total) !== length) throw new ProtocolError("PLP total length mismatch") + return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total) +} + +const sized = (data: Buffer, sizes: ReadonlyArray): void => { + if (!sizes.includes(data.length)) throw new ProtocolError(`Invalid TDS value size ${data.length}`) +} + +export const value = (r: Reader, c: Column, maxValueSize: number): unknown => { + const type = c.type + let data: Buffer | null + if (type in fixedSizes) { + data = r.take(fixedSizes[type]) + } else if (type === 0xf1 || type === 0xf0 || c.length === 0xffff) { + data = plp(r, maxValueSize) + } else if ([0xa5, 0xad, 0xa7, 0xaf, 0xe7, 0xef].includes(type)) { + const length = r.u16() + if (length === 0xffff) return null + if (length > c.length) throw new ProtocolError("Value exceeds column length") + data = r.take(length) + } else if ([0x22, 0x23, 0x63].includes(type)) { + const pointerLength = r.u8() + if (pointerLength === 0) return null + r.take(pointerLength + 8) + const length = r.u32() + if (length > maxValueSize) throw new ProtocolError("TDS value exceeds configured size limit") + data = r.take(length) + } else if (type === 0x62) { + const length = r.u32() + if (length === 0) return null + if (length > 8016) throw new ProtocolError("SQL_VARIANT exceeds type size limit") + const variant = new Reader(r.take(length), true) + const baseType = variant.u8() + const properties = new Reader(variant.take(variant.u8()), true) + let column: Column = { name: "", type: baseType, length: fixedSizes[baseType] ?? 0, scale: 0, precision: 0 } + if (baseType === 0x6a || baseType === 0x6c) { + column = { ...column, precision: properties.u8(), scale: properties.u8() } + } else if ([0x29, 0x2a, 0x2b].includes(baseType)) { + column = { ...column, scale: properties.u8() } + } else if ([0xa7, 0xaf, 0xe7, 0xef].includes(baseType)) { + column = { ...column, collation: Buffer.from(properties.take(5)), length: properties.u16() } + } else if ([0xa5, 0xad].includes(baseType)) { + column = { ...column, length: properties.u16() } + } else if (!(baseType in fixedSizes) && baseType !== 0x24 && baseType !== 0x28) { + throw new ProtocolError("Invalid SQL_VARIANT base type") + } + if (properties.offset !== properties.data.length) throw new ProtocolError("Invalid SQL_VARIANT properties") + const body = variant.take(variant.data.length - variant.offset) + let prefix = Buffer.alloc(0) + if ([0xa5, 0xad, 0xa7, 0xaf, 0xe7, 0xef].includes(baseType)) { + prefix = Buffer.alloc(2) + prefix.writeUInt16LE(body.length) + } else if (!(baseType in fixedSizes)) prefix = Buffer.from([body.length]) + const reader = new Reader(Buffer.concat([prefix, body]), true) + const result = value(reader, column, maxValueSize) + if (reader.offset !== reader.data.length) throw new ProtocolError("SQL_VARIANT length mismatch") + return result + } else { + const length = r.u8() + if (length === 0) return null + data = r.take(length) + } + if (data === null) return null + switch (type) { + case 0x1f: + return null + case 0x30: + return data[0] + case 0x34: + return data.readInt16LE(0) + case 0x38: + return data.readInt32LE(0) + case 0x7f: + return data.readBigInt64LE(0).toString() + case 0x26: + sized(data, [1, 2, 4, 8]) + return data.length === 8 ? + data.readBigInt64LE(0).toString() : + data.length === 1 + ? data[0] + : data.readIntLE(0, data.length) + case 0x32: + case 0x68: + sized(data, [1]) + return data[0] !== 0 + case 0x3b: + return data.readFloatLE(0) + case 0x3e: + return data.readDoubleLE(0) + case 0x6d: + sized(data, [4, 8]) + return data.length === 4 ? data.readFloatLE(0) : data.readDoubleLE(0) + case 0x7a: + case 0x3c: + case 0x6e: + sized(data, [4, 8]) + return data.length === 4 ? + data.readInt32LE(0) / 10000 : + (data.readInt32LE(0) * 0x100000000 + data.readUInt32LE(4)) / 10000 + case 0x6a: + case 0x6c: { + sized(data, [5, 9, 13, 17]) + let n = BigInt("0") + for (let i = data.length - 1; i > 0; i--) n = (n << BigInt("8")) | BigInt(data[i]) + if (data[0] > 1) throw new ProtocolError("Invalid decimal sign") + return Number(n) / 10 ** c.scale * (data[0] === 0 ? -1 : 1) + } + case 0x24: { + sized(data, [16]) + const hex = data.toString("hex").toUpperCase() + return `${hex.slice(6, 8)}${hex.slice(4, 6)}${hex.slice(2, 4)}${hex.slice(0, 2)}-${hex.slice(10, 12)}${ + hex.slice(8, 10) + }-${hex.slice(14, 16)}${hex.slice(12, 14)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + case 0x3a: + case 0x3d: + case 0x6f: + sized(data, [4, 8]) + return data.length === 4 ? + new Date(datetimeEpoch + data.readUInt16LE(0) * 86400000 + data.readUInt16LE(2) * 60000) : + new Date(datetimeEpoch + data.readInt32LE(0) * 86400000 + Math.round(data.readUInt32LE(4) * 10 / 3)) + case 0x28: + sized(data, [3]) + return new Date(dateEpoch + data.readUIntLE(0, 3) * 86400000) + case 0x29: + case 0x2a: + case 0x2b: { + const timeLength = c.scale <= 2 ? 3 : c.scale <= 4 ? 4 : 5 + sized(data, [timeLength + (type === 0x29 ? 0 : type === 0x2a ? 3 : 5)]) + const time = data.readUIntLE(0, timeLength) / 10 ** c.scale * 1000 + const days = type === 0x29 ? 0 : data.readUIntLE(timeLength, 3) + // DATETIMEOFFSET's date/time fields are already UTC on the wire. + return new Date((type === 0x29 ? 0 : dateEpoch) + days * 86400000 + Math.floor(time)) + } + case 0xa7: + case 0xaf: + case 0xe7: + case 0xef: + case 0x23: + case 0x63: + case 0xf1: + return characters(data, c) + case 0xa5: + case 0xad: + case 0x22: + case 0xf0: + return Buffer.from(data) + default: + throw new ProtocolError(`Unsupported value type ${type}`) + } +} + +/** Incremental token decoder. Buffer capacity grows geometrically under fragmentation. */ +export class TokenParser { + private buffer = Buffer.alloc(4096) + private start = 0 + private endOffset = 0 + columns: ReadonlyArray | undefined + + readonly maxTokenSize: number + constructor(maxTokenSize = 16 * 1024 * 1024) { + if (!Number.isSafeInteger(maxTokenSize) || maxTokenSize < 1) throw new ProtocolError("Invalid token size limit") + this.maxTokenSize = maxTokenSize + } + + push(chunk: Buffer, consume: (token: Token) => void): void { + // Process bounded pieces so a large socket chunk of small rows does not + // count as one oversized token. + for (let offset = 0; offset < chunk.length;) { + const pending = this.endOffset - this.start + const size = Math.min(chunk.length - offset, this.maxTokenSize - pending) + if (size === 0) throw new ProtocolError("TDS token exceeds configured size limit") + if (this.endOffset + size > this.buffer.length) { + if (pending + size <= this.buffer.length) { + this.buffer.copyWithin(0, this.start, this.endOffset) + } else { + const next = Buffer.allocUnsafe(Math.min(this.maxTokenSize, Math.max(this.buffer.length * 2, pending + size))) + this.buffer.copy(next, 0, this.start, this.endOffset) + this.buffer = next + } + this.start = 0 + this.endOffset = pending + } + chunk.copy(this.buffer, this.endOffset, offset, offset + size) + this.endOffset += size + offset += size + while (this.start < this.endOffset) { + const reader = new Reader(this.buffer.subarray(this.start, this.endOffset)) + let token: Token + try { + token = this.read(reader) + } catch (error) { + if (error === incomplete) break + throw error + } + this.start += reader.offset + if (token._tag === "Metadata") this.columns = token.columns + consume(token) + } + if (this.start === this.endOffset) this.start = this.endOffset = 0 + } + } + + end(): void { + if (this.start !== this.endOffset) throw new ProtocolError("Truncated TDS token at end of message") + } + + private read(r: Reader): Token { + const kind = r.u8() + switch (kind) { + case 0x81: { + const count = r.u16() + if (count === 0xffff) return { _tag: "Ignored" } + const columns: Array = [] + for (let i = 0; i < count; i++) { + const column = metadata(r) + if ([0x22, 0x23, 0x63].includes(column.type)) { + const parts = r.u8() + for (let p = 0; p < parts; p++) r.usString() + } + columns.push({ ...column, name: r.bString() }) + } + return { _tag: "Metadata", columns } + } + case 0xd1: + case 0xd2: { + const columns = this.columns + if (!columns) throw new ProtocolError("ROW received before COLMETADATA") + const nulls = kind === 0xd2 ? r.take(Math.ceil(columns.length / 8)) : undefined + const values = new Array(columns.length) + for (let i = 0; i < columns.length; i++) { + values[i] = nulls && (nulls[i >> 3] & (1 << (i & 7))) !== 0 ? null : value(r, columns[i], this.maxTokenSize) + } + return { _tag: "Row", values } + } + case 0xfd: + case 0xfe: + case 0xff: { + const body = r.take(12) + return { _tag: "Done", kind, status: body.readUInt16LE(0), rowCount: body.readBigUInt64LE(4) } + } + case 0xaa: + case 0xab: { + const body = new Reader(r.take(r.u16()), true) + const error = { + number: body.u32(), + state: body.u8(), + class: body.u8(), + message: body.usString(), + serverName: body.bString(), + procName: body.bString(), + lineNumber: body.u32() + } + return { _tag: kind === 0xaa ? "Error" : "Info", error } + } + case 0xe3: + return { _tag: "EnvChange", data: Buffer.from(r.take(r.u16())) } + case 0xad: { + const body = new Reader(r.take(r.u16()), true) + body.u8() + const version = body.take(4).readUInt32BE(0) + body.bString() + body.take(4) + return { _tag: "LoginAck", version } + } + case 0x79: + return { _tag: "ReturnStatus", value: r.take(4).readInt32LE(0) } + case 0xac: { + r.u16() + const name = r.bString() + r.u8() + const column = metadata(r) + return { + _tag: "ReturnValue", + name: name.startsWith("@") ? name.slice(1) : name, + value: value(r, column, this.maxTokenSize) + } + } + case 0xed: + return { _tag: "Sspi", data: Buffer.from(r.take(r.u16())) } + case 0xa4: + case 0xa5: + case 0xa9: + r.take(r.u16()) + return { _tag: "Ignored" } + case 0xe4: + case 0xee: + r.take(r.u32()) + return { _tag: "Ignored" } + case 0xae: + while (r.u8() !== 0xff) r.take(r.u32()) + return { _tag: "Ignored" } + default: + throw new ProtocolError(`Unexpected TDS token 0x${kind.toString(16)}`) + } + } +} diff --git a/packages/sql/mssql/test/Binary.test.ts b/packages/sql/mssql/test/Binary.test.ts index 4a3044ebcfc..e17416bc8ff 100644 --- a/packages/sql/mssql/test/Binary.test.ts +++ b/packages/sql/mssql/test/Binary.test.ts @@ -2,33 +2,17 @@ import { MssqlClient } from "@effect/sql-mssql" import { assert, it } from "@effect/vitest" import { Effect } from "effect" import * as Reactivity from "effect/unstable/reactivity/Reactivity" -import type * as Tedious from "tedious" import { vi } from "vitest" -vi.mock("tedious", async (importOriginal) => { - const original = await importOriginal() - - class MockConnection extends original.Connection { - override connect(callback?: (error?: Error) => void) { - callback?.() - } - override close() {} - override cancel() { - return false - } - override makeRequest(request: Tedious.Request | Tedious.BulkLoad) { - if (!(request instanceof original.Request)) { - throw new Error("Unexpected bulk load") - } - const rows = request.parameters.map((parameter) => [ - { metadata: { colName: "value" }, value: parameter.value } - ]) - request.callback(null, rows.length, rows) - } - } - - return { ...original, Connection: MockConnection } -}) +vi.mock("#tds/tdsConnection", () => ({ + make: () => + Effect.succeed({ + query: (_sql: string, parameters: ReadonlyArray<{ value: unknown }>) => + Effect.succeed({ rows: parameters.map((parameter) => ({ value: parameter.value })), output: {} }), + batch: () => Effect.succeed({ rows: [], output: {} }), + onClose: () => () => {} + }) +})) it.effect("binds an interpolated Uint8Array as VarBinary", () => Effect.gen(function*() { diff --git a/packages/sql/mssql/test/Client.test.ts b/packages/sql/mssql/test/Client.test.ts index be8b98aa9b5..585bb598d7a 100644 --- a/packages/sql/mssql/test/Client.test.ts +++ b/packages/sql/mssql/test/Client.test.ts @@ -3,67 +3,25 @@ import { assert, describe, expect, it } from "@effect/vitest" import { Effect, Fiber } from "effect" import * as Reactivity from "effect/unstable/reactivity/Reactivity" import * as Statement from "effect/unstable/sql/Statement" -import type * as Tedious from "tedious" import { vi } from "vitest" const state = vi.hoisted(() => ({ cancelCalls: 0, completeRequests: true, type: {} })) -vi.mock("tedious", async (importOriginal) => { - const original = await importOriginal() - - class MockRequest { - readonly listeners: Record) => void> = {} - - constructor( - readonly sql: string, - readonly callback: (cause: unknown, rowCount: number, rows: ReadonlyArray) => void - ) {} - - addParameter() {} - addOutputParameter() {} - on(event: string, listener: (...args: Array) => void) { - this.listeners[event] = listener - } - } - - class MockConnection { - connect(callback: (cause: unknown) => void) { - callback(null) - } - close() {} - on() {} - cancel() { - state.cancelCalls++ - } - execSql(request: MockRequest) { - if (state.completeRequests) { - request.callback(null, 0, []) - } - } - callProcedure(request: MockRequest) { - request.listeners.returnValue("answer", 42) - request.callback(null, 0, []) - } - beginTransaction(callback: (cause: unknown) => void) { - callback(null) - } - commitTransaction(callback: (cause: unknown) => void) { - callback(null) - } - saveTransaction(callback: (cause: unknown) => void) { - callback(null) - } - rollbackTransaction(callback: (cause: unknown) => void) { - callback(null) - } - } - - return { - ...original, - Connection: MockConnection, - Request: MockRequest - } -}) +vi.mock("#tds/tdsConnection", () => ({ + make: () => + Effect.succeed({ + query: () => + Effect.callback((resume) => { + if (state.completeRequests) resume(Effect.succeed({ rows: [], output: {} })) + return Effect.sync(() => { + state.cancelCalls++ + }) + }), + batch: () => Effect.succeed({ rows: [], output: {} }), + call: () => Effect.succeed({ rows: [], output: { answer: 42 } }), + onClose: () => () => {} + }) +})) const sql = Statement.make(Effect.void as any, MssqlClient.makeCompiler(), [], undefined) @@ -212,7 +170,7 @@ describe("mssql", () => { Effect.provide(Reactivity.layer) )) - it.effect("cancels an in-flight Tedious request when interrupted", () => + it.effect("cancels an in-flight native request when interrupted", () => Effect.gen(function*() { state.cancelCalls = 0 state.completeRequests = true diff --git a/packages/sql/mssql/test/SqlErrorClassification.test.ts b/packages/sql/mssql/test/SqlErrorClassification.test.ts index 689e6df3b58..ead64cae5db 100644 --- a/packages/sql/mssql/test/SqlErrorClassification.test.ts +++ b/packages/sql/mssql/test/SqlErrorClassification.test.ts @@ -12,84 +12,24 @@ const state: { requestCauses: [] } -class MockRequest { - callback: (cause: unknown, rowCount: number, rows: ReadonlyArray) => void - - constructor( - _sql: string, - callback: (cause: unknown, rowCount: number, rows: ReadonlyArray) => void - ) { - this.callback = callback +vi.mock("#tds/tdsConnection", async () => { + const { SqlError, UnknownError, ConnectionError } = await import("effect/unstable/sql/SqlError") + const query = () => + Effect.suspend(() => { + const cause = state.requestCauses.shift() + return cause ? + Effect.fail(new SqlError({ reason: new UnknownError({ cause }) })) : + Effect.succeed({ rows: [], output: {} }) + }) + return { + make: () => + Effect.suspend(() => + state.connectCause ? + Effect.fail(new SqlError({ reason: new ConnectionError({ cause: state.connectCause }) })) : + Effect.succeed({ query, call: query, batch: query, onClose: () => () => {} }) + ) } - - addParameter() { - return - } - - addOutputParameter() { - return - } - - on() { - return - } -} - -class MockConnection { - connect(callback: (cause: unknown) => void) { - callback(state.connectCause) - } - - close() { - return - } - - on() { - return - } - - beginTransaction(callback: (cause: unknown) => void) { - callback(null) - } - - commitTransaction(callback: (cause: unknown) => void) { - callback(null) - } - - saveTransaction(callback: (cause: unknown) => void) { - callback(null) - } - - rollbackTransaction(callback: (cause: unknown) => void) { - callback(null) - } - - cancel() { - return - } - - execSql(request: MockRequest) { - const cause = state.requestCauses.length > 0 ? state.requestCauses.shift() : null - request.callback(cause, 0, []) - } - - callProcedure(request: MockRequest) { - this.execSql(request) - } -} - -vi.mock("tedious", () => ({ - Connection: MockConnection, - Request: MockRequest, - TYPES: { - VarChar: {}, - Int: {}, - BigInt: {}, - Bit: {}, - DateTime: {}, - VarBinary: {} - } -})) +}) const queryFailureReason = (cause: unknown) => Effect.gen(function*() { diff --git a/packages/sql/mssql/test/TdsConnection.in-process.test.ts b/packages/sql/mssql/test/TdsConnection.in-process.test.ts new file mode 100644 index 00000000000..26c2bcc8d13 --- /dev/null +++ b/packages/sql/mssql/test/TdsConnection.in-process.test.ts @@ -0,0 +1,252 @@ +import * as Connection from "#tds/tdsConnection" +import { responseKey } from "#tds/tdsNtlm" +import * as Packet from "#tds/tdsPacket" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Fiber } from "effect" +import { Buffer } from "node:buffer" +import { createHmac } from "node:crypto" +import * as Dgram from "node:dgram" +import * as Net from "node:net" + +const done = (status = 0) => { + const b = Buffer.alloc(13) + b[0] = 0xfd + b.writeUInt16LE(status, 1) + return b +} +const login = Buffer.concat([Buffer.from("ad0a0001740000040010000000", "hex"), done()]) +const result = Buffer.concat([Buffer.from("81010000000000000038017800d12a000000", "hex"), done()]) +const send = (socket: Net.Socket, data: Buffer) => socket.write(Packet.encode(Packet.RESPONSE, data)) + +const server = (handle: (socket: Net.Socket, type: number, data: Buffer) => void) => + Effect.acquireRelease( + Effect.callback((resume) => { + const sockets = new Set() + const server = Net.createServer((socket) => { + sockets.add(socket) + socket.on("close", () => sockets.delete(socket)) + socket.on("error", () => {}) + const packets = new Packet.PacketParser() + const messages = new Packet.MessageParser() + socket.on("data", (chunk: Buffer) => + packets.push(chunk, (packet) => { + const data = messages.push(packet) + if (!data) return + if (packet.type === Packet.PRELOGIN) send(socket, Packet.prelogin(false)) + else handle(socket, packet.type, data) + })) + }) + server.on("error", (error) => resume(Effect.fail(error))) + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))) + Object.assign(server, { + closeSockets: () => { + for (const socket of sockets) socket.destroy() + } + }) + return Effect.sync(() => server.close()) + }), + (server) => + Effect.promise(() => + new Promise((resolve) => { + ;(server as Net.Server & { closeSockets: () => void }).closeSockets() + server.close(() => resolve()) + }) + ) + ) + +const settings = (server: Net.Server): Connection.Config => ({ + server: "127.0.0.1", + port: (server.address() as Net.AddressInfo).port, + encrypt: false, + connectTimeoutMs: 1000, + cancelTimeoutMs: 100, + initializeSession: false +}) +const delay = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))) + +describe("native TDS connection lifecycle", () => { + it.effect("performs an NTLMv2 exchange in LOGIN7 and SSPI packets", () => + Effect.scoped(Effect.gen(function*() { + const nonce = Buffer.from("0123456789abcdef", "hex") + const challenge = Buffer.alloc(52) + challenge.write("NTLMSSP\0", "ascii") + challenge.writeUInt32LE(2, 8) + challenge.writeUInt32LE(0xa2888205, 20) + nonce.copy(challenge, 24) + challenge.writeUInt16LE(4, 40) + challenge.writeUInt16LE(4, 42) + challenge.writeUInt32LE(48, 44) + let verified = false + const peer = yield* server((socket, type, data) => { + if (type === Packet.LOGIN7) { + expect(data[25] & 0x80).toBe(0x80) + expect(data.readUInt16LE(42)).toBe(0) + expect(data.toString("ascii", data.readUInt16LE(78), data.readUInt16LE(78) + 8)).toBe("NTLMSSP\0") + send(socket, Buffer.concat([Buffer.from([0xed, challenge.length, 0]), challenge])) + } else if (type === 0x11) { + const ntOffset = data.readUInt32LE(24) + const ntLength = data.readUInt16LE(20) + const proof = createHmac("md5", responseKey("User", "Domain", "Password")) + .update(Buffer.concat([nonce, data.subarray(ntOffset + 16, ntOffset + ntLength)])).digest() + expect(data.subarray(ntOffset, ntOffset + 16)).toEqual(proof) + verified = true + send(socket, login) + } else send(socket, result) + }) + const session = yield* Connection.make({ + ...settings(peer), + authType: "ntlm", + username: "User", + domain: "Domain", + password: "Password" + }) + expect(verified).toBe(true) + expect((yield* session.query("SELECT 42")).rows).toEqual([{ x: 42 }]) + }))) + + it.effect("retries transient login errors within the configured budget", () => + Effect.scoped(Effect.gen(function*() { + let attempts = 0 + const peer = yield* server((socket, type) => { + if (type === Packet.LOGIN7 && ++attempts === 1) { + const error = Buffer.alloc(17) + error[0] = 0xaa + error.writeUInt16LE(14, 1) + error.writeUInt32LE(40613, 3) + error[7] = 1 + error[8] = 16 + send(socket, Buffer.concat([error, done(2)])) + } else send(socket, type === Packet.LOGIN7 ? login : result) + }) + const session = yield* Connection.make({ + ...settings(peer), + connectionRetryIntervalMs: 10, + maxRetriesOnTransientErrors: 1 + }) + expect(attempts).toBe(2) + expect((yield* session.query("SELECT 42")).rows).toEqual([{ x: 42 }]) + }))) + + it.effect("times out requests through ATTENTION and then permits reuse", () => + Effect.scoped(Effect.gen(function*() { + let queries = 0 + const peer = yield* server((socket, type) => { + if (type === Packet.LOGIN7) send(socket, login) + else if (type === Packet.ATTENTION) send(socket, done(0x20)) + else if (++queries > 1) send(socket, result) + }) + const session = yield* Connection.make({ ...settings(peer), requestTimeoutMs: 20 }) + expect((yield* Effect.result(session.query("WAITFOR")))._tag).toBe("Failure") + expect(session.closed).toBe(false) + expect((yield* session.query("SELECT 42")).rows).toEqual([{ x: 42 }]) + }))) + + it.effect("resolves named instances using a validated SQL Browser response", () => + Effect.scoped(Effect.gen(function*() { + const browser = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const socket = Dgram.createSocket("udp4") + socket.on("error", (error) => resume(Effect.fail(error))) + socket.on("message", (message, remote) => { + expect(message).toEqual(Buffer.concat([Buffer.from([4]), Buffer.from("NATIVE\0")])) + const body = Buffer.from("ServerName;local;InstanceName;NATIVE;tcp;14339;;") + const header = Buffer.alloc(3) + header[0] = 5 + header.writeUInt16LE(body.length, 1) + socket.send(Buffer.concat([header, body]), remote.port, remote.address) + }) + socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket))) + return Effect.sync(() => socket.close()) + }), + (socket) => Effect.sync(() => socket.close()) + ) + expect(yield* Connection.instancePort("127.0.0.1", "NATIVE", 1000, browser.address().port)).toBe(14339) + }))) + + it.effect("accepts fragmented login and query responses", () => + Effect.scoped(Effect.gen(function*() { + const peer = yield* server((socket, type) => { + const wire = Packet.encode(Packet.RESPONSE, type === Packet.LOGIN7 ? login : result) + for (let i = 0; i < wire.length; i++) socket.write(wire.subarray(i, i + 1)) + }) + const session = yield* Connection.make(settings(peer)) + expect((yield* session.query("SELECT 42")).rows).toEqual([{ x: 42 }]) + }))) + + it.effect("destroys malformed sessions and refuses reuse", () => + Effect.scoped(Effect.gen(function*() { + const peer = yield* server((socket, type) => send(socket, type === Packet.LOGIN7 ? login : Buffer.from([0xd1]))) + const session = yield* Connection.make(settings(peer)) + expect((yield* Effect.result(session.query("SELECT 42")))._tag).toBe("Failure") + expect(session.closed).toBe(true) + expect((yield* Effect.result(session.query("SELECT 43")))._tag).toBe("Failure") + }))) + + it.effect("waits for the attention acknowledgement before admitting another request", () => + Effect.scoped(Effect.gen(function*() { + let queries = 0 + let acknowledged = false + const peer = yield* server((socket, type) => { + if (type === Packet.LOGIN7) send(socket, login) + else if (type === Packet.ATTENTION) { + setTimeout(() => { + acknowledged = true + send(socket, done(0x20)) + }, 30) + } else if (++queries > 1) { + expect(acknowledged).toBe(true) + send(socket, result) + } + }) + const session = yield* Connection.make(settings(peer)) + const first = yield* Effect.forkChild(session.query("WAITFOR")) + yield* delay(20) + const second = yield* Effect.forkChild(session.query("SELECT")) + yield* Fiber.interrupt(first) + expect((yield* Fiber.join(second)).rows).toEqual([{ x: 42 }]) + }))) + + it.effect("closes the socket when the server never acknowledges cancellation", () => + Effect.scoped(Effect.gen(function*() { + const peer = yield* server((socket, type) => { + if (type === Packet.LOGIN7) send(socket, login) + }) + const session = yield* Connection.make({ ...settings(peer), cancelTimeoutMs: 20 }) + const fiber = yield* Effect.forkChild(session.query("WAITFOR")) + yield* delay(20) + yield* Fiber.interrupt(fiber) + expect(session.closed).toBe(true) + }))) + + it.effect("bounds incomplete startup and validates configuration before connecting", () => + Effect.scoped(Effect.gen(function*() { + const peer = yield* server(() => {}) + expect((yield* Effect.result(Connection.make({ ...settings(peer), connectTimeoutMs: 20 })))._tag).toBe("Failure") + expect((yield* Effect.result(Connection.make({ ...settings(peer), username: "x".repeat(129) })))._tag).toBe( + "Failure" + ) + expect((yield* Effect.result(Connection.make({ ...settings(peer), packetSize: 0 })))._tag).toBe("Failure") + }))) + + it.effect("follows a validated login routing response", () => + Effect.scoped(Effect.gen(function*() { + const target = yield* server((socket, type) => send(socket, type === Packet.LOGIN7 ? login : result)) + const targetPort = (target.address() as Net.AddressInfo).port + const source = yield* server((socket) => { + const host = Buffer.from("127.0.0.1", "utf16le") + const route = Buffer.alloc(5 + host.length) + route.writeUInt16LE(targetPort, 1) + route.writeUInt16LE(host.length / 2, 3) + host.copy(route, 5) + const env = Buffer.alloc(3 + 1 + 2 + route.length + 2) + env[0] = 0xe3 + env.writeUInt16LE(env.length - 3, 1) + env[3] = 20 + env.writeUInt16LE(route.length, 4) + route.copy(env, 6) + send(socket, Buffer.concat([env, done()])) + }) + const session = yield* Connection.make(settings(source)) + expect((yield* session.query("SELECT 42")).rows).toEqual([{ x: 42 }]) + }))) +}) diff --git a/packages/sql/mssql/test/TdsConnection.integration.test.ts b/packages/sql/mssql/test/TdsConnection.integration.test.ts new file mode 100644 index 00000000000..625d89816a4 --- /dev/null +++ b/packages/sql/mssql/test/TdsConnection.integration.test.ts @@ -0,0 +1,205 @@ +import * as Connection from "#tds/tdsConnection" +import { TYPES } from "#tds/tdsRequest" +import { MssqlClient, MssqlTypes, Procedure } from "@effect/sql-mssql" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Fiber, Redacted } from "effect" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" + +const config: Connection.Config = { + server: process.env.MSSQL_HOST ?? "127.0.0.1", + port: Number(process.env.MSSQL_PORT ?? 14339), + username: "sa", + password: process.env.MSSQL_PASSWORD ?? "Effect_Tds_Test_7426!", + encrypt: true, + trustServer: true +} + +describe("native TDS / SQL Server", () => { + it.effect("roundtrips decimal, money, legacy LOB, XML, and ANSI parameters", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const result = yield* session.query( + "SELECT @decimal AS d, @money AS m, @text AS t, @ntext AS nt, @image AS i, @xml AS x, @ansi AS a", + [ + { name: "decimal", type: TYPES.Decimal, value: "-123456789.12345", options: { precision: 20, scale: 5 } }, + { name: "money", type: TYPES.Money, value: "-123.4567" }, + { name: "text", type: TYPES.Text, value: "café €" }, + { name: "ntext", type: TYPES.NText, value: "λ 🎵" }, + { name: "image", type: TYPES.Image, value: new Uint8Array([0, 255]) }, + { name: "xml", type: TYPES.Xml, value: "λ" }, + { name: "ansi", type: TYPES.VarChar, value: "café €" } + ] + ) + expect(result.rows).toEqual([{ + d: -123456789.12345, + m: -123.4567, + t: "café €", + nt: "λ 🎵", + i: Buffer.from([0, 255]), + x: "λ", + a: "café €" + }]) + }))) + + it.effect("decodes SQL_VARIANT values and non-default collations", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const result = yield* session.query(`SELECT CAST(42 AS sql_variant) AS i, + CAST(CAST(-1.25 AS decimal(10,2)) AS sql_variant) AS d, + CAST(N'λ' AS sql_variant) AS s, + CAST(N'Привет' COLLATE Cyrillic_General_CI_AS AS varchar(20)) AS ru, + CAST(N'日本語' COLLATE Japanese_CI_AS AS varchar(20)) AS ja`) + expect(result.rows).toEqual([{ i: 42, d: -1.25, s: "λ", ru: "Привет", ja: "日本語" }]) + }))) + + it.effect("sends table-valued parameters with stable column metadata", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const typeName = `effect_native_table_${process.pid}` + yield* session.batch(`CREATE TYPE [${typeName}] AS TABLE (n int, s nvarchar(20))`) + yield* Effect.addFinalizer(() => Effect.orDie(session.batch(`DROP TYPE [${typeName}]`))) + const result = yield* session.query("SELECT n, s FROM @items ORDER BY n", [{ + name: "items", + type: TYPES.TVP, + value: { + name: typeName, + columns: [{ name: "n", type: TYPES.Int }, { name: "s", type: TYPES.NVarChar, length: 20 }], + rows: [[1, "λ"], [2, null], [3, "longer"]] + } + }]) + expect(result.rows).toEqual([{ n: 1, s: "λ" }, { n: 2, s: null }, { n: 3, s: "longer" }]) + }))) + + it.effect("negotiates TLS and executes parameterized Unicode, numbers, and binary", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const result = yield* session.query("SELECT @text AS text, @number AS number, @bytes AS bytes", [ + { name: "text", type: TYPES.NVarChar, value: "λ 🎵" }, + { name: "number", type: TYPES.Float, value: 1.5 }, + { name: "bytes", type: TYPES.VarBinary, value: new Uint8Array([0, 255, 1]) } + ]) + expect(result.rows).toEqual([{ text: "λ 🎵", number: 1.5, bytes: Buffer.from([0, 255, 1]) }]) + }))) + + it.effect("roundtrips large PLP values and preserves positional column order", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const text = "λ".repeat(10000) + const result = yield* session.query("SELECT @text AS x, CAST(NULL AS int) AS x, 42 AS [__proto__]", [ + { name: "text", type: TYPES.NVarChar, value: text } + ], true) + expect(result.rows).toEqual([[text, null, 42]]) + }))) + + it.effect("drains query errors and keeps the connection usable", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const result = yield* Effect.result(session.query("SELECT * FROM effect_native_missing_table")) + expect(result._tag).toBe("Failure") + expect((yield* session.query("SELECT 42 AS answer")).rows).toEqual([{ answer: 42 }]) + }))) + + it.effect("tracks transaction descriptors and supports savepoint rollback", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + yield* session.batch("CREATE TABLE #native_tds (n int); BEGIN TRANSACTION") + yield* session.query("INSERT INTO #native_tds VALUES (@n)", [{ name: "n", type: TYPES.Int, value: 1 }]) + yield* session.batch("SAVE TRANSACTION effect_save") + yield* session.query("INSERT INTO #native_tds VALUES (2)") + yield* session.batch("ROLLBACK TRANSACTION effect_save") + yield* session.batch("COMMIT TRANSACTION") + expect((yield* session.query("SELECT n FROM #native_tds")).rows).toEqual([{ n: 1 }]) + }))) + + it.effect("cancels WAITFOR and drains ATTENTION before the next query", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const fiber = yield* Effect.forkChild(session.query("WAITFOR DELAY '00:00:10'; SELECT 1 AS stale")) + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))) + yield* Fiber.interrupt(fiber) + expect((yield* session.query("SELECT 42 AS fresh")).rows).toEqual([{ fresh: 42 }]) + }))) + + it.effect("serializes concurrent requests on one physical connection", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const results = yield* Effect.forEach(Array.from({ length: 20 }, (_, i) => i), (i) => + session.query("SELECT @n AS n", [{ name: "n", type: TYPES.Int, value: i }]), { concurrency: "unbounded" }) + expect(results.map((r) => + r.rows[0].n + )).toEqual(Array.from({ length: 20 }, (_, i) => i)) + }))) + + it.effect("returns RPC output parameters and return status", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + yield* session.batch( + "CREATE PROCEDURE #native_answer @input int, @answer int OUTPUT AS BEGIN SET @answer = @input + 1; SELECT @answer AS answer; RETURN 7; END" + ) + const result = yield* session.call("#native_answer", [ + { name: "input", type: TYPES.Int, value: 41 }, + { name: "answer", type: TYPES.Int, value: null, output: true } + ]) + expect(result.rows).toEqual([{ answer: 42 }]) + expect(result.output).toEqual({ answer: 42 }) + expect(result.returnStatus).toBe(7) + }))) + + it.effect("rejects invalid credentials and untrusted certificates", () => + Effect.scoped(Effect.gen(function*() { + expect((yield* Effect.result(Connection.make({ ...config, password: "invalid" })))._tag).toBe("Failure") + expect((yield* Effect.result(Connection.make({ ...config, trustServer: false })))._tag).toBe("Failure") + }))) + + it.effect("roundtrips dates, UUIDs, bigint boundaries, nulls, and empty MAX values", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const timestamp = new Date("2024-02-29T12:34:56.123Z") + const result = yield* session.query( + "SELECT @date AS date, @id AS id, @big AS big, @empty AS empty, @nothing AS nothing", + [ + { name: "date", type: TYPES.DateTime2, value: timestamp }, + { name: "id", type: TYPES.UniqueIdentifier, value: "12345678-abcd-ef01-2345-6789abcdef01" }, + { name: "big", type: TYPES.BigInt, value: -9223372036854775808n }, + { name: "empty", type: TYPES.NVarChar, value: "", options: { length: Infinity } }, + { name: "nothing", type: TYPES.VarBinary, value: null, options: { length: Infinity } } + ] + ) + expect(result.rows).toEqual([{ + date: timestamp, + id: "12345678-ABCD-EF01-2345-6789ABCDEF01", + big: "-9223372036854775808", + empty: "", + nothing: null + }]) + }))) + + it.effect("runs through the public pooled adapter with nested transactions", () => + Effect.scoped(Effect.gen(function*() { + const sql = yield* MssqlClient.make({ ...config, password: Redacted.make(config.password!), maxConnections: 1 }) + const table = `effect_native_public_${process.pid}` + const procedureName = `effect_native_answer_${process.pid}` + yield* Effect.addFinalizer(() => + Effect.orDie(sql`DROP TABLE IF EXISTS ${sql(table)}; DROP PROCEDURE IF EXISTS ${sql(procedureName)}`) + ) + expect(yield* sql`SELECT ${new Uint8Array([0, 128, 255])} AS bytes`).toEqual([{ + bytes: Buffer.from([0, 128, 255]) + }]) + yield* sql.withTransaction(Effect.gen(function*() { + yield* sql`CREATE TABLE ${sql(table)} (n int)` + yield* sql`INSERT INTO ${sql(table)} VALUES (${1})` + yield* Effect.result(sql.withTransaction(Effect.gen(function*() { + yield* sql`INSERT INTO ${sql(table)} VALUES (${2})` + return yield* Effect.fail("rollback savepoint") + }))) + expect(yield* sql`SELECT n FROM ${sql(table)}`).toEqual([{ n: 1 }]) + })) + yield* sql`CREATE PROCEDURE ${sql(procedureName)} @answer int OUTPUT AS SET @answer = 42` + const procedure = Procedure.make(procedureName).pipe( + Procedure.outputParam()("answer", MssqlTypes.Int), + Procedure.compile + ) + expect(yield* sql.call(procedure({}))).toEqual({ output: { answer: 42 }, rows: [] }) + expect(yield* sql.withTransaction(sql.call(procedure({})))).toEqual({ output: { answer: 42 }, rows: [] }) + })).pipe(Effect.provide(Reactivity.layer))) +}) diff --git a/packages/sql/mssql/test/TdsNtlm.test.ts b/packages/sql/mssql/test/TdsNtlm.test.ts new file mode 100644 index 00000000000..ea5bd03583b --- /dev/null +++ b/packages/sql/mssql/test/TdsNtlm.test.ts @@ -0,0 +1,63 @@ +import { authenticate, negotiate, responseKey } from "#tds/tdsNtlm" +import { describe, expect, it } from "@effect/vitest" +import { Buffer } from "node:buffer" + +const challenge = ( + target = Buffer.from("02000c0044006f006d00610069006e0001000c0053006500720076006500720000000000", "hex") +) => { + const header = Buffer.alloc(56) + header.write("NTLMSSP\0", "ascii") + header.writeUInt32LE(2, 8) + header.writeUInt32LE(0xe28a8233, 20) + Buffer.from("0123456789abcdef", "hex").copy(header, 24) + header.writeUInt16LE(target.length, 40) + header.writeUInt16LE(target.length, 42) + header.writeUInt32LE(56, 44) + return Buffer.concat([header, target]) +} + +const credentials = { username: "User", domain: "Domain", password: "Password" } + +describe("NTLMv2", () => { + it("matches Microsoft's response-key and challenge-response examples", () => { + // MS-NLMP 4.2.4.2.1 and 4.2.4.2.2. + expect(responseKey("User", "Domain", "Password").toString("hex")).toBe("0c868a403bfd7a93a3001ef22ef02e3f") + const response = authenticate(challenge(), credentials, { + nonce: Buffer.alloc(8, 0xaa), + timestamp: Buffer.alloc(8) + }) + const lmOffset = response.readUInt32LE(16) + const ntOffset = response.readUInt32LE(24) + expect(response.subarray(lmOffset, lmOffset + 24).toString("hex")).toBe( + "86c35097ac9cec102554764a57cccc19aaaaaaaaaaaaaaaa" + ) + expect(response.subarray(ntOffset, ntOffset + 16).toString("hex")).toBe("68cd0ab851e51c96aabc927bebef6a1c") + expect(response.readUInt16LE(20)).toBeGreaterThan(16) + }) + + it("uses the server timestamp and includes a MIC when supplied", () => { + const target = Buffer.from("07000800000000000000000000000000", "hex") + const response = authenticate(challenge(target), credentials, { nonce: Buffer.alloc(8, 0xaa) }) + const lmOffset = response.readUInt32LE(16) + expect(response.subarray(lmOffset, lmOffset + 24)).toEqual(Buffer.alloc(24)) + expect(response.subarray(72, 88).equals(Buffer.alloc(16))).toBe(false) + }) + + it("rejects malformed challenges and target information", () => { + expect(() => authenticate(Buffer.alloc(0), credentials)).toThrow("Invalid NTLM challenge") + const invalid = challenge() + invalid.writeUInt32LE(0xffffffff, 44) + expect(() => authenticate(invalid, credentials)).toThrow("security buffer") + expect(() => authenticate(challenge(Buffer.from([1, 0, 255, 255])), credentials)).toThrow("target info") + const legacy = challenge() + legacy.writeUInt32LE(1, 20) + expect(() => authenticate(legacy, credentials)).toThrow("NTLMv2") + }) + + it("writes a versioned Unicode NTLM negotiate message without signing or key exchange", () => { + const message = negotiate() + expect(message.toString("ascii", 0, 8)).toBe("NTLMSSP\0") + expect(message.readUInt32LE(8)).toBe(1) + expect(message.readUInt32LE(12) & 0x40000030).toBe(0) + }) +}) diff --git a/packages/sql/mssql/test/TdsPacket.test.ts b/packages/sql/mssql/test/TdsPacket.test.ts new file mode 100644 index 00000000000..f35f49810ec --- /dev/null +++ b/packages/sql/mssql/test/TdsPacket.test.ts @@ -0,0 +1,84 @@ +import * as Packet from "#tds/tdsPacket" +import { describe, expect, it } from "@effect/vitest" +import { Buffer } from "node:buffer" + +describe("TDS packets", () => { + it("decodes every two-chunk split including empty packets and multiple messages", () => { + const payload = Buffer.alloc(1800, 0x5a) + const wire = Buffer.concat([ + Packet.encode(Packet.SQL_BATCH, payload, 512), + Packet.encode(Packet.ATTENTION, Buffer.alloc(0)) + ]) + for (let split = 0; split <= wire.length; split++) { + const parser = new Packet.PacketParser() + const packets: Array = [] + parser.push(wire.subarray(0, split), (packet) => packets.push(packet)) + parser.push(wire.subarray(split), (packet) => packets.push(packet)) + parser.end() + expect(packets.map((p) => p.status)).toEqual([0, 0, 0, 1, 1]) + expect(Buffer.concat(packets.slice(0, 4).map((p) => p.data))).toEqual(payload) + expect(packets[4].type).toBe(Packet.ATTENTION) + expect(packets[4].data.length).toBe(0) + } + }) + + it("handles one-byte fragments and packet id wraparound", () => { + const payload = Buffer.alloc(504 * 257, 0x7b) + const wire = Packet.encode(Packet.RPC, payload, 512) + expect(wire[255 * 512 + 6]).toBe(0) + expect(wire[256 * 512 + 6]).toBe(1) + const parser = new Packet.PacketParser() + const messages = new Packet.MessageParser() + let result: Buffer | undefined + for (let i = 0; i < wire.length; i++) { + parser.push(wire.subarray(i, i + 1), (p) => { + result = messages.push(p) + }) + } + parser.end() + messages.end() + expect(result).toEqual(payload) + }) + + it("rejects invalid framing and truncated input", () => { + const invalid = Buffer.from([4, 1, 0, 7, 0, 0, 1, 0]) + expect(() => new Packet.PacketParser().push(invalid, () => {})).toThrow("length") + const wire = Packet.encode(Packet.RESPONSE, Buffer.from([1, 2, 3])) + for (let end = 1; end < wire.length; end++) { + const parser = new Packet.PacketParser() + parser.push(wire.subarray(0, end), () => {}) + expect(() => parser.end()).toThrow("inside a TDS packet") + } + expect(() => Packet.encode(1, Buffer.alloc(0), 65536)).toThrow("packet size") + }) + + it("bounds startup assembly and rejects mixed message types", () => { + const parser = new Packet.MessageParser(2) + parser.push({ type: 4, status: 0, data: Buffer.from([1, 2]) }) + expect(() => parser.end()).toThrow("message") + expect(() => parser.push({ type: 4, status: 1, data: Buffer.from([3]) })).toThrow("limit") + const mixed = new Packet.MessageParser() + mixed.push({ type: 4, status: 0, data: Buffer.from([1]) }) + expect(() => mixed.push({ type: 18, status: 1, data: Buffer.from([2]) })).toThrow("type changed") + }) + + it("encodes and validates PRELOGIN option offsets", () => { + expect(Packet.preloginEncryption(Packet.prelogin(true))).toBe(1) + expect(Packet.preloginEncryption(Packet.prelogin(false))).toBe(2) + expect(() => Packet.preloginEncryption(Buffer.from([1, 0]))).toThrow("Truncated") + const invalid = Packet.prelogin(true) + invalid.writeUInt16BE(0, 6) + expect(() => Packet.preloginEncryption(invalid)).toThrow("overlaps") + }) + + it("writes UTF-16 login offsets and obfuscates the password", () => { + const data = Packet.login({ server: "localhost", username: "sa", password: "abc", database: "λ" }) + expect(data.readUInt32LE(0)).toBe(data.length) + expect(data.readUInt32LE(4)).toBe(0x74000004) + expect(data.readUInt16LE(46)).toBe(3) + const offset = data.readUInt16LE(44) + expect(data.subarray(offset, offset + 6).toString("hex")).toBe("b3a583a593a5") + expect(data.toString("utf16le", data.readUInt16LE(68), data.readUInt16LE(68) + 2)).toBe("λ") + expect(() => Packet.login({ server: "x".repeat(129) })).toThrow("128") + }) +}) diff --git a/packages/sql/mssql/test/TdsRequest.test.ts b/packages/sql/mssql/test/TdsRequest.test.ts new file mode 100644 index 00000000000..b4e79dcb66d --- /dev/null +++ b/packages/sql/mssql/test/TdsRequest.test.ts @@ -0,0 +1,48 @@ +import * as Request from "#tds/tdsRequest" +import { describe, expect, it } from "@effect/vitest" +import { Buffer } from "node:buffer" + +const collation = Buffer.from("0904d00034", "hex") +const parameter = (type: Request.DataType, value: unknown, options?: Request.ParameterOptions) => + Request.encodeParameter({ name: "x", type, value, options }, collation) + +describe("TDS request encoding", () => { + it("rejects invalid values before they reach the wire", () => { + expect(() => parameter(Request.TYPES.TinyInt, 256)).toThrow("integer") + expect(() => parameter(Request.TYPES.Int, 1.5)).toThrow("integer") + expect(() => parameter(Request.TYPES.Float, NaN)).toThrow("finite") + expect(() => parameter(Request.TYPES.BigInt, Number.MAX_SAFE_INTEGER + 1)).toThrow("safe integer") + expect(() => parameter(Request.TYPES.NVarChar, "long", { length: 2 })).toThrow("declared length") + expect(() => parameter(Request.TYPES.UniqueIdentifier, "bad uuid")).toThrow("UUID") + expect(() => parameter(Request.TYPES.Decimal, "1234", { precision: 3 })).toThrow("precision") + expect(() => parameter(Request.TYPES.Decimal, "1", { precision: 2, scale: 3 })).toThrow("integer") + expect(() => parameter(Request.TYPES.Date, new Date(NaN))).toThrow("valid Date") + expect(() => parameter(Request.TYPES.DateTime, new Date("1000-01-01Z"))).toThrow("range") + expect(() => Request.encodeParameter({ name: "x; DROP TABLE t", type: Request.TYPES.Int, value: 1 }, collation)) + .toThrow("parameter name") + }) + + it("encodes exact decimal strings through 38 digits without floating-point multiplication", () => { + const encoded = parameter(Request.TYPES.Decimal, "99999999999999999999999999999999999999", { precision: 38 }) + const bytes = encoded.subarray(-16) + let magnitude = BigInt(0) + for (let i = 15; i >= 0; i--) magnitude = (magnitude << BigInt(8)) | BigInt(bytes[i]) + expect(magnitude.toString()).toBe("99999999999999999999999999999999999999") + const rounded = parameter(Request.TYPES.Decimal, "-1.005", { precision: 5, scale: 2 }) + expect(rounded.subarray(-5)).toEqual(Buffer.from([0, 101, 0, 0, 0])) + }) + + it("carries rounded datetime ticks and smalldatetime minutes into the next day", () => { + const a = parameter(Request.TYPES.DateTime, new Date("2024-01-01T23:59:59.999Z")) + const b = parameter(Request.TYPES.DateTime, new Date("2024-01-02T00:00:00.000Z")) + expect(a).toEqual(b) + expect(parameter(Request.TYPES.SmallDateTime, new Date("2024-01-01T23:59:45Z"))) + .toEqual(parameter(Request.TYPES.SmallDateTime, new Date("2024-01-02T00:00:00Z"))) + }) + + it("validates TVP column counts and cell values before encoding an RPC", () => { + const value = { name: "Items", columns: [{ name: "n", type: Request.TYPES.Int }], rows: [[1, 2]] } + expect(() => parameter(Request.TYPES.TVP, value)).toThrow("does not match") + expect(() => parameter(Request.TYPES.TVP, { ...value, rows: [["invalid"]] })).toThrow("integer") + }) +}) diff --git a/packages/sql/mssql/test/TdsToken.test.ts b/packages/sql/mssql/test/TdsToken.test.ts new file mode 100644 index 00000000000..035186e019f --- /dev/null +++ b/packages/sql/mssql/test/TdsToken.test.ts @@ -0,0 +1,71 @@ +import { type Token, TokenParser } from "#tds/tdsToken" +import { describe, expect, it } from "@effect/vitest" +import { Buffer } from "node:buffer" + +const done = Buffer.from("fd100000000100000000000000", "hex") +const intColumn = (name: string) => + Buffer.concat([ + Buffer.from("00000000000038", "hex"), + Buffer.from([name.length]), + Buffer.from(name, "utf16le") + ]) +const metadata = Buffer.concat([Buffer.from([0x81, 2, 0]), intColumn("a"), intColumn("b")]) +const row = Buffer.from([0xd1, 42, 0, 0, 0, 255, 255, 255, 255]) +const nbcRow = Buffer.from([0xd2, 1, 7, 0, 0, 0]) + +describe("TDS tokens", () => { + it("handles every split through metadata, ROW, NBCROW, and DONE", () => { + const data = Buffer.concat([metadata, row, nbcRow, done]) + for (let i = 0; i <= data.length; i++) { + const parser = new TokenParser() + const tokens: Array = [] + parser.push(data.subarray(0, i), (token) => tokens.push(token)) + parser.push(data.subarray(i), (token) => tokens.push(token)) + parser.end() + expect(tokens.map((t) => t._tag)).toEqual(["Metadata", "Row", "Row", "Done"]) + expect(tokens[1]).toEqual({ _tag: "Row", values: [42, -1] }) + expect(tokens[2]).toEqual({ _tag: "Row", values: [null, 7] }) + expect(tokens[3]).toEqual({ _tag: "Done", kind: 0xfd, status: 16, rowCount: BigInt(1) }) + } + }) + + it("handles bytewise PLP fragmentation without retaining mutable result buffers", () => { + const columns = Buffer.from("810100000000000000a5ffff017800", "hex") + const plp = Buffer.from("d10300000000000000020000000102010000000300000000", "hex") + const data = Buffer.concat([columns, plp, done]) + const parser = new TokenParser() + const rows: Array> = [] + for (let i = 0; i < data.length; i++) { + parser.push(data.subarray(i, i + 1), (t) => { + if (t._tag === "Row") rows.push(t.values) + }) + } + parser.end() + parser.push(Buffer.concat(Array.from({ length: 400 }, () => done)), () => {}) + expect(rows).toEqual([[Buffer.from([1, 2, 3])]]) + }) + + it("rejects unexpected tokens, rows without metadata, and truncated messages", () => { + expect(() => new TokenParser().push(Buffer.from([0]), () => {})).toThrow("Unexpected") + expect(() => new TokenParser().push(row, () => {})).toThrow("before COLMETADATA") + const parser = new TokenParser() + parser.push(done.subarray(0, 10), () => {}) + expect(() => parser.end()).toThrow("Truncated") + }) + + it("rejects malformed length-delimited tokens immediately", () => { + expect(() => new TokenParser().push(Buffer.from([0xaa, 1, 0, 0]), () => {})).toThrow("Malformed") + }) + + it("enforces a token bound but permits large chunks of small tokens", () => { + const parser = new TokenParser(32) + let count = 0 + parser.push(Buffer.concat(Array.from({ length: 1000 }, () => done)), () => count++) + parser.end() + expect(count).toBe(1000) + const invalid = new TokenParser(16) + expect(() => invalid.push(Buffer.concat([Buffer.from([0xaa, 100, 0]), Buffer.alloc(100)]), () => {})).toThrow( + "limit" + ) + }) +}) diff --git a/packages/sql/mssql/test/utils.ts b/packages/sql/mssql/test/utils.ts index 2941f0bd346..5245c20823b 100644 --- a/packages/sql/mssql/test/utils.ts +++ b/packages/sql/mssql/test/utils.ts @@ -1,6 +1,8 @@ import { MssqlClient } from "@effect/sql-mssql" import { MSSQLServerContainer } from "@testcontainers/mssqlserver" import { Context, Data, Effect, Layer, Redacted } from "effect" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import { randomUUID } from "node:crypto" export class ContainerError extends Data.TaggedError("ContainerError")<{ cause: unknown @@ -20,17 +22,35 @@ export class MssqlContainer extends Context.Service()("test/Mssq }) { static readonly layer = Layer.effect(this)(this.make) - static layerClient = Layer.unwrap( - Effect.gen(function*() { - const container = yield* MssqlContainer - return MssqlClient.layer({ - server: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - username: container.getUsername(), - password: Redacted.make(container.getPassword()), + static layerClient = process.env.MSSQL_PORT ? + Layer.unwrap(Effect.gen(function*() { + const config = { + server: process.env.MSSQL_HOST ?? "127.0.0.1", + port: Number(process.env.MSSQL_PORT), + username: process.env.MSSQL_USERNAME ?? "sa", + password: Redacted.make(process.env.MSSQL_PASSWORD ?? "Effect_Tds_Test_7426!"), + database: process.env.MSSQL_DATABASE ?? "master", trustServer: true + } + const admin = yield* MssqlClient.make(config) + // These suites assume fresh storage and use a TestClock. Reusing the + // container must not reuse persisted values from earlier test runs. + const database = `effect_tds_test_${randomUUID().replaceAll("-", "")}` + yield* admin`CREATE DATABASE ${admin(database)}` + yield* Effect.addFinalizer(() => Effect.orDie(admin`DROP DATABASE ${admin(database)}`)) + return MssqlClient.layer({ ...config, database }) + })).pipe(Layer.provide(Reactivity.layer)) : + Layer.unwrap( + Effect.gen(function*() { + const container = yield* MssqlContainer + return MssqlClient.layer({ + server: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + username: container.getUsername(), + password: Redacted.make(container.getPassword()), + trustServer: true + }) }) - }) - ).pipe(Layer.provide(this.layer)) + ).pipe(Layer.provide(this.layer)) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba273b14eff..a316bc66def 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -551,9 +551,12 @@ importers: packages/sql/mssql: dependencies: - tedious: - specifier: ^20.0.0 - version: 20.0.0(supports-color@10.2.2) + iconv-lite: + specifier: ^0.7.0 + version: 0.7.3 + js-md4: + specifier: ^0.3.2 + version: 0.3.2 devDependencies: '@testcontainers/mssqlserver': specifier: ^12.1.0 @@ -561,6 +564,9 @@ importers: effect: specifier: workspace:^ version: link:../../effect + tedious: + specifier: ^20.0.0 + version: 20.0.0(supports-color@10.2.2) packages/sql/mysql2: dependencies: From 4c17edfd78eb0d642f426bf5a64c70126b883e92 Mon Sep 17 00:00:00 2001 From: Joep Joosten Date: Tue, 8 Sep 2026 15:21:45 +0200 Subject: [PATCH 2/4] Add native access-token FedAuth and make Docker tests self-contained --- .changeset/native-mssql-tds.md | 5 +- packages/sql/mssql/README.md | 14 +- packages/sql/mssql/benchmark/README.md | 7 +- packages/sql/mssql/src/MssqlClient.ts | 39 ++++- .../sql/mssql/src/internal/tdsConnection.ts | 30 +++- packages/sql/mssql/src/internal/tdsPacket.ts | 58 +++++- packages/sql/mssql/src/internal/tdsToken.ts | 12 +- .../test/TdsConnection.integration.test.ts | 30 +++- .../mssql/test/TdsFedAuth.in-process.test.ts | 165 ++++++++++++++++++ packages/sql/mssql/test/TdsPacket.test.ts | 26 ++- packages/sql/mssql/test/TdsToken.test.ts | 17 ++ .../sql/mssql/test/fixtures/tls/README.md | 13 ++ packages/sql/mssql/test/fixtures/tls/cert.pem | 19 ++ packages/sql/mssql/test/fixtures/tls/key.pem | 28 +++ 14 files changed, 437 insertions(+), 26 deletions(-) create mode 100644 packages/sql/mssql/test/TdsFedAuth.in-process.test.ts create mode 100644 packages/sql/mssql/test/fixtures/tls/README.md create mode 100644 packages/sql/mssql/test/fixtures/tls/cert.pem create mode 100644 packages/sql/mssql/test/fixtures/tls/key.pem diff --git a/.changeset/native-mssql-tds.md b/.changeset/native-mssql-tds.md index e874b16587c..1b156ce26df 100644 --- a/.changeset/native-mssql-tds.md +++ b/.changeset/native-mssql-tds.md @@ -3,9 +3,10 @@ --- Replace the tedious runtime dependency with an Effect-native TDS 7.4 driver, -including TLS, SQL and NTLMv2 authentication, parameter and result codecs, +including TLS, SQL, NTLMv2 and access-token FedAuth authentication, parameter and result codecs, stored procedures, table-valued parameters, transaction-aware calls and safe cancellation. Export native MssqlTypes descriptors and add requestTimeout. -Azure/Entra authentication is not yet supported. Native parameter descriptors +Automatic Azure credential flows are not yet supported; applications can provide +an Effect-based access-token provider. Native parameter descriptors replace tedious descriptors; see the package README for compatibility limits. diff --git a/packages/sql/mssql/README.md b/packages/sql/mssql/README.md index e374cf36525..74075c60478 100644 --- a/packages/sql/mssql/README.md +++ b/packages/sql/mssql/README.md @@ -17,7 +17,7 @@ npm install effect@rc @effect/sql-mssql@rc ## Native driver -The driver supports encrypted SQL authentication, NTLMv2, named-instance discovery, +The driver supports encrypted SQL authentication, NTLMv2, access-token FedAuth, named-instance discovery, server-directed routing, parameterized queries, stored procedures with output parameters, table-valued parameters, and nested transactions using savepoints. Interrupted or timed-out requests send ATTENTION and drain its acknowledgement @@ -33,9 +33,19 @@ Use `MssqlTypes` from `@effect/sql-mssql` for procedure parameter descriptors an SQL `bigint` results remain strings; decimal and numeric results remain JavaScript numbers, which can lose precision. Exact decimal input can be supplied as a string. +For Azure SQL, provide `accessToken` as an Effect returning a redacted access +token for the SQL service. It runs for each new pooled connection, allowing the +application's credential provider to refresh expired tokens. Token acquisition +is bounded by `connectTimeout`, and requires TLS. The driver does not acquire +credentials from Azure CLI, managed identity, or environment variables itself. +`authType: "azure-active-directory-access-token"` is optional when `accessToken` +is provided. The token must target the Azure SQL service, not Azure management APIs. + Compatibility limits of this implementation: -- Azure/Entra authentication is not implemented; unsupported `authType` values fail explicitly. +- Security Token FedAuth has encrypted protocol tests, but has not been verified + against live Azure SQL. Automatic Azure credential flows and ADAL/FEDAUTHINFO + negotiation are not implemented; other Azure `authType` values fail explicitly. - NTLMv2 has protocol/vector tests, but has not been verified against a live Windows domain. Extended Protection/channel binding is not implemented. - Streaming queries remain unsupported. Results are buffered, with a 16 MiB per-token diff --git a/packages/sql/mssql/benchmark/README.md b/packages/sql/mssql/benchmark/README.md index 3f44c67915b..d898aa55344 100644 --- a/packages/sql/mssql/benchmark/README.md +++ b/packages/sql/mssql/benchmark/README.md @@ -77,5 +77,8 @@ timeouts. Live SQL tests cover TLS, SQL authentication, scalar/LOB/TVP codecs, procedures, output parameters, errors, transactions and public adapter behavior. Existing persistence/cache/queue integration tests also run against the container. -Windows-domain NTLM interoperability and Extended Protection, Azure/Entra -authentication, and other SQL Server versions remain outside verified coverage. +Security Token FedAuth also has TLS peer tests, including required acknowledgements, +echo flags and per-connection token acquisition. These do not establish live Azure +interoperability. Windows-domain NTLM, Extended Protection, live Azure SQL, +automatic Azure credential flows, and other SQL Server versions remain outside +verified coverage. diff --git a/packages/sql/mssql/src/MssqlClient.ts b/packages/sql/mssql/src/MssqlClient.ts index 4a98053436f..b575aa257cb 100644 --- a/packages/sql/mssql/src/MssqlClient.ts +++ b/packages/sql/mssql/src/MssqlClient.ts @@ -213,11 +213,13 @@ export interface MssqlClientConfig { */ readonly trustServer?: boolean | undefined readonly port?: number | undefined - /** Authentication method: `default` (SQL credentials) or `ntlm` (requires `domain`). */ + /** Authentication method: `default`, `ntlm` (requires `domain`), or `azure-active-directory-access-token` (requires `accessToken`). */ readonly authType?: string | undefined readonly database?: string | undefined readonly username?: string | undefined readonly password?: Redacted.Redacted | undefined + /** Effect obtaining a fresh Azure SQL access token for each physical connection. Requires TLS. */ + readonly accessToken?: Effect.Effect | undefined readonly connectTimeout?: Duration.Input | undefined readonly cancelTimeout?: Duration.Input | undefined /** Time before requesting cancellation. Defaults to 15 seconds; zero disables the request timer. Cancellation is drained before reuse. */ @@ -287,7 +289,10 @@ export const make = ( let pool: Pool.Pool const makeConnection = Effect.gen(function*() { - if (options.authType && options.authType !== "default" && options.authType !== "ntlm") { + if ( + options.authType && options.authType !== "default" && options.authType !== "ntlm" && + options.authType !== "azure-active-directory-access-token" + ) { return yield* Effect.fail( new SqlError({ reason: new AuthenticationError({ @@ -298,6 +303,33 @@ export const make = ( }) ) } + if ( + (options.authType === "azure-active-directory-access-token" && options.accessToken === undefined) || + (options.accessToken !== undefined && (options.authType === "ntlm" || options.encrypt === false)) + ) { + return yield* Effect.fail( + new SqlError({ + reason: new AuthenticationError({ + cause: undefined, + message: "Access-token authentication requires an accessToken effect, TLS, and no NTLM authentication", + operation: "connect" + }) + }) + ) + } + const accessToken = options.accessToken === undefined ? undefined : yield* options.accessToken.pipe( + Effect.timeout(options.connectTimeout ?? Duration.seconds(15)), + Effect.map(Redacted.value), + Effect.mapError((cause) => + new SqlError({ + reason: new AuthenticationError({ + cause, + message: "Failed to obtain SQL access token", + operation: "connect" + }) + }) + ) + ) const mapError = (error: SqlError) => new SqlError({ reason: classifyError( @@ -312,7 +344,8 @@ export const make = ( port: options.port, instanceName: options.instanceName, multiSubnetFailover: options.multiSubnetFailover, - authType: options.authType as "default" | "ntlm" | undefined, + authType: options.authType === "ntlm" ? "ntlm" : "default", + accessToken, domain: options.domain, maxRetriesOnTransientErrors: options.maxRetriesOnTransientErrors, connectionRetryIntervalMs: options.connectionRetryInterval diff --git a/packages/sql/mssql/src/internal/tdsConnection.ts b/packages/sql/mssql/src/internal/tdsConnection.ts index d2f0c064f9d..01a933e3843 100644 --- a/packages/sql/mssql/src/internal/tdsConnection.ts +++ b/packages/sql/mssql/src/internal/tdsConnection.ts @@ -86,6 +86,7 @@ export class Session { private pending: Pending | undefined private loginAck = false private loginDone = false + private fedAuthAck = false private loginError: SqlError | undefined private route: RoutingChange | undefined private readonly loginPayload: Buffer @@ -104,6 +105,9 @@ export class Session { this.config = config this.connected = connected this.packetSize = config.packetSize ?? 4096 + if (config.accessToken !== undefined && (config.encrypt === false || config.authType === "ntlm")) { + throw new Packet.ProtocolError("Federated authentication requires TLS and cannot be combined with NTLM") + } if (config.authType === "ntlm" && !config.domain) throw new Packet.ProtocolError("NTLM requires a domain") this.loginPayload = Packet.login(config.authType === "ntlm" ? { ...config, sspi: Ntlm.negotiate() } : config) if (!Number.isInteger(this.packetSize) || this.packetSize < 512 || this.packetSize > 32767) { @@ -144,7 +148,10 @@ export class Session { }) this.socket.once( "connect", - () => this.socket.write(Packet.encode(Packet.PRELOGIN, Packet.prelogin(config.encrypt ?? true))) + () => + this.socket.write( + Packet.encode(Packet.PRELOGIN, Packet.prelogin(config.encrypt ?? true, config.accessToken !== undefined)) + ) ) } @@ -256,6 +263,7 @@ export class Session { if (this.closed) return const connecting = this.state !== "ready" this.state = "closed" + this.loginPayload.fill(0) clearTimeout(this.connectTimer) this.tls?.destroy() this.bridge?.destroy() @@ -285,7 +293,11 @@ export class Session { if (this.state === "prelogin") { const message = this.messages.push(packet) if (!message) return - const encryption = Packet.preloginEncryption(message) + const { encryption, fedAuthRequired } = Packet.preloginOptions(message) + if (this.config.accessToken !== undefined) { + const featureOffset = this.loginPayload.readUInt32LE(this.loginPayload.readUInt16LE(56)) + this.loginPayload[featureOffset + 5] = 2 | (fedAuthRequired ? 1 : 0) + } if (encryption === 1 || encryption === 3) this.startTls() else if (this.config.encrypt !== false) throw new Packet.ProtocolError("Server refused required encryption") else if (encryption === 2) this.sendLogin() @@ -318,6 +330,9 @@ export class Session { return } if (!this.loginAck || !this.loginDone) throw new Packet.ProtocolError("Incomplete LOGIN7 response") + if (this.config.accessToken !== undefined && !this.fedAuthAck) { + throw new Packet.ProtocolError("Missing federated authentication acknowledgement") + } this.state = "ready" clearTimeout(this.connectTimer) this.connected(Effect.succeed(this)) @@ -381,6 +396,17 @@ export class Session { private onToken(token: Token): void { const pending = this.pending switch (token._tag) { + case "FeatureAck": { + if (this.state !== "login") throw new Packet.ProtocolError("Unexpected feature acknowledgement") + const fedAuth = token.features.get(2) + if (fedAuth !== undefined) { + if (this.config.accessToken === undefined || this.fedAuthAck || fedAuth.length !== 0) { + throw new Packet.ProtocolError("Invalid federated authentication acknowledgement") + } + this.fedAuthAck = true + } + break + } case "LoginAck": if (token.version !== 0x74000004) throw new Packet.ProtocolError("Server did not negotiate TDS 7.4") this.loginAck = true diff --git a/packages/sql/mssql/src/internal/tdsPacket.ts b/packages/sql/mssql/src/internal/tdsPacket.ts index 1333dd37ff0..65cba8f6549 100644 --- a/packages/sql/mssql/src/internal/tdsPacket.ts +++ b/packages/sql/mssql/src/internal/tdsPacket.ts @@ -135,18 +135,19 @@ export class MessageParser { } /** MS-TDS 2.2.6.5: VERSION, ENCRYPTION, INSTOPT, THREADID, MARS. */ -export const prelogin = (encrypt: boolean): Buffer => { +export const prelogin = (encrypt: boolean, fedAuth = false): Buffer => { const entries = [ Buffer.from([0, 0, 0, 0, 0, 0]), Buffer.from([encrypt ? 1 : 2]), Buffer.from([0]), Buffer.alloc(4), - Buffer.from([0]) + Buffer.from([0]), + ...(fedAuth ? [Buffer.from([1])] : []) ] const header = Buffer.alloc(entries.length * 5 + 1) let offset = header.length for (let i = 0; i < entries.length; i++) { - header[i * 5] = i + header[i * 5] = i === 5 ? 6 : i // FEDAUTHREQUIRED is option 0x06 header.writeUInt16BE(offset, i * 5 + 1) header.writeUInt16BE(entries[i].length, i * 5 + 3) offset += entries[i].length @@ -155,8 +156,9 @@ export const prelogin = (encrypt: boolean): Buffer => { return Buffer.concat([header, ...entries]) } -export const preloginEncryption = (data: Buffer): number => { +export const preloginOptions = (data: Buffer): { encryption: number; fedAuthRequired: boolean } => { let encryption: number | undefined + let fedAuth: number | undefined let offset = 0 const ranges: Array = [] while (offset < data.length && data[offset] !== 0xff) { @@ -169,6 +171,12 @@ export const preloginEncryption = (data: Buffer): number => { if (size !== 1 || encryption !== undefined) throw new ProtocolError("Invalid PRELOGIN encryption option") encryption = data[start] } + if (data[offset] === 6) { + if (size !== 1 || fedAuth !== undefined || data[start] > 1) { + throw new ProtocolError("Invalid PRELOGIN FEDAUTHREQUIRED option") + } + fedAuth = data[start] + } offset += 5 } if (offset >= data.length) throw new ProtocolError("Missing PRELOGIN terminator") @@ -176,9 +184,11 @@ export const preloginEncryption = (data: Buffer): number => { if (start <= offset) throw new ProtocolError("PRELOGIN option overlaps header") } if (encryption === undefined || encryption > 3) throw new ProtocolError("Missing or invalid PRELOGIN encryption") - return encryption + return { encryption, fedAuthRequired: fedAuth === 1 } } +export const preloginEncryption = (data: Buffer): number => preloginOptions(data).encryption + export interface LoginOptions { readonly server: string readonly username?: string | undefined @@ -187,10 +197,18 @@ export interface LoginOptions { readonly applicationName?: string | undefined readonly packetSize?: number | undefined readonly sspi?: Buffer | undefined + readonly accessToken?: string | undefined + readonly fedAuthEcho?: boolean | undefined } -/** SQL authentication LOGIN7, TDS 7.4, without optional feature extensions. */ +/** TDS 7.4 LOGIN7 with UTF-8 support and optional Security Token FedAuth. */ export const login = (options: LoginOptions): Buffer => { + if ( + options.accessToken !== undefined && + (options.sspi || options.accessToken.length === 0 || options.accessToken.length > 60000) + ) { + throw new ProtocolError("Invalid federated authentication token or conflicting SSPI authentication") + } const header = Buffer.alloc(94) header.writeUInt32LE(0x74000004, 4) header.writeUInt32LE(options.packetSize ?? 4096, 8) @@ -198,11 +216,11 @@ export const login = (options: LoginOptions): Buffer => { header[24] = 0xe0 // little endian, ASCII, IEEE, database notification, fatal database error header[25] = 0x03 // fatal language error and ODBC session semantics if (options.sspi) header[25] |= 0x80 - header[27] = 0x08 // unknown collation handling + header[27] = 0x18 // unknown collation handling and feature extensions const fields = [ [36, "effect"], - [40, options.sspi ? "" : options.username ?? ""], - [44, options.sspi ? "" : options.password ?? ""], + [40, options.sspi || options.accessToken !== undefined ? "" : options.username ?? ""], + [44, options.sspi || options.accessToken !== undefined ? "" : options.password ?? ""], [48, options.applicationName ?? "@effect/sql-mssql"], [52, options.server], [56, ""], @@ -233,6 +251,28 @@ export const login = (options: LoginOptions): Buffer => { parts.push(options.sspi) offset += options.sspi.length } + // ibExtension points to a DWORD containing the absolute FeatureExt offset. + if (offset > 65535) throw new ProtocolError("LOGIN7 variable fields exceed offset limit") + header.writeUInt16LE(offset, 56) + header.writeUInt16LE(4, 58) + const pointer = Buffer.alloc(4) + pointer.writeUInt32LE(offset + 4) + parts.push(pointer) + offset += 4 + if (options.accessToken !== undefined) { + const token = Buffer.from(options.accessToken, "utf16le") + const feature = Buffer.alloc(10) + feature[0] = 2 + feature.writeUInt32LE(token.length + 5, 1) + feature[5] = 2 | (options.fedAuthEcho ? 1 : 0) + feature.writeUInt32LE(token.length, 6) + parts.push(feature, token) + offset += feature.length + token.length + } + const utf8 = Buffer.from([0x0a, 1, 0, 0, 0, 1, 0xff]) + parts.push(utf8) + offset += utf8.length + if (offset > 131071) throw new ProtocolError("LOGIN7 exceeds protocol length limit") header.writeUInt32LE(offset, 0) return Buffer.concat(parts, offset) } diff --git a/packages/sql/mssql/src/internal/tdsToken.ts b/packages/sql/mssql/src/internal/tdsToken.ts index 257fb6ba9fc..329a72606f1 100644 --- a/packages/sql/mssql/src/internal/tdsToken.ts +++ b/packages/sql/mssql/src/internal/tdsToken.ts @@ -69,6 +69,7 @@ export type Token = | { readonly _tag: "ReturnStatus"; readonly value: number } | { readonly _tag: "ReturnValue"; readonly name: string; readonly value: unknown } | { readonly _tag: "Sspi"; readonly data: Buffer } + | { readonly _tag: "FeatureAck"; readonly features: ReadonlyMap } | { readonly _tag: "Ignored" } const fixedSizes: Readonly> = { @@ -454,9 +455,14 @@ export class TokenParser { case 0xee: r.take(r.u32()) return { _tag: "Ignored" } - case 0xae: - while (r.u8() !== 0xff) r.take(r.u32()) - return { _tag: "Ignored" } + case 0xae: { + const features = new Map() + for (let id = r.u8(); id !== 0xff; id = r.u8()) { + if (features.has(id)) throw new ProtocolError("Duplicate feature acknowledgement") + features.set(id, Buffer.from(r.take(r.u32()))) + } + return { _tag: "FeatureAck", features } + } default: throw new ProtocolError(`Unexpected TDS token 0x${kind.toString(16)}`) } diff --git a/packages/sql/mssql/test/TdsConnection.integration.test.ts b/packages/sql/mssql/test/TdsConnection.integration.test.ts index 625d89816a4..772a29e07fd 100644 --- a/packages/sql/mssql/test/TdsConnection.integration.test.ts +++ b/packages/sql/mssql/test/TdsConnection.integration.test.ts @@ -2,10 +2,12 @@ import * as Connection from "#tds/tdsConnection" import { TYPES } from "#tds/tdsRequest" import { MssqlClient, MssqlTypes, Procedure } from "@effect/sql-mssql" import { describe, expect, it } from "@effect/vitest" +import { MSSQLServerContainer } from "@testcontainers/mssqlserver" import { Effect, Fiber, Redacted } from "effect" import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import { afterAll, beforeAll } from "vitest" -const config: Connection.Config = { +let config: Connection.Config = { server: process.env.MSSQL_HOST ?? "127.0.0.1", port: Number(process.env.MSSQL_PORT ?? 14339), username: "sa", @@ -14,6 +16,25 @@ const config: Connection.Config = { trustServer: true } +let container: { stop: () => Promise } | undefined +beforeAll(async () => { + if (process.env.MSSQL_PORT) return + const started = await new MSSQLServerContainer("mcr.microsoft.com/mssql/server:2022-latest") + .acceptLicense().start() + container = started + config = { + ...config, + server: started.getHost(), + port: started.getPort(), + database: started.getDatabase(), + username: started.getUsername(), + password: started.getPassword() + } +}, 120000) +afterAll(async () => { + await container?.stop() +}, 60000) + describe("native TDS / SQL Server", () => { it.effect("roundtrips decimal, money, legacy LOB, XML, and ANSI parameters", () => Effect.scoped(Effect.gen(function*() { @@ -176,7 +197,12 @@ describe("native TDS / SQL Server", () => { it.effect("runs through the public pooled adapter with nested transactions", () => Effect.scoped(Effect.gen(function*() { - const sql = yield* MssqlClient.make({ ...config, password: Redacted.make(config.password!), maxConnections: 1 }) + const sql = yield* MssqlClient.make({ + ...config, + accessToken: undefined, + password: Redacted.make(config.password!), + maxConnections: 1 + }) const table = `effect_native_public_${process.pid}` const procedureName = `effect_native_answer_${process.pid}` yield* Effect.addFinalizer(() => diff --git a/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts b/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts new file mode 100644 index 00000000000..1e3770fd0c8 --- /dev/null +++ b/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts @@ -0,0 +1,165 @@ +import * as Connection from "#tds/tdsConnection" +import * as Packet from "#tds/tdsPacket" +import { MssqlClient } from "@effect/sql-mssql" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Redacted } from "effect" +import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import { Buffer } from "node:buffer" +import { readFileSync } from "node:fs" +import * as Net from "node:net" +import * as Tls from "node:tls" + +const done = Buffer.from("fd000000000000000000000000", "hex") +const loginAck = Buffer.from("ad0a0001740000040010000000", "hex") +// The proxy wraps the TLS server's handshake records in TDS packets, then +// forwards encrypted application traffic unchanged. +const peer = (ack: Buffer, echo = true) => + Effect.acquireRelease( + Effect.tryPromise(async () => { + const sockets = new Set() + const states = new Map() + const tlsServer = Tls.createServer({ + key: readFileSync(new URL("./fixtures/tls/key.pem", import.meta.url)), + cert: readFileSync(new URL("./fixtures/tls/cert.pem", import.meta.url)), + maxVersion: "TLSv1.2", + requestCert: false + }, (socket) => { + const state = states.get(socket.remotePort!)! + state.secure = true + const packets = new Packet.PacketParser() + const messages = new Packet.MessageParser() + socket.on("error", () => {}) + socket.on("data", (chunk: Buffer) => + packets.push(chunk, (packet) => { + state.applicationData = true + const data = messages.push(packet) + if (!data) return + if (packet.type === Packet.LOGIN7) { + const feature = data.readUInt32LE(data.readUInt16LE(56)) + expect(data[feature]).toBe(2) + expect(data[feature + 5]).toBe(echo ? 3 : 2) + const length = data.readUInt32LE(feature + 6) + expect(data.toString("utf16le", feature + 10, feature + 10 + length)).toBe("test-token") + socket.write(Packet.encode(Packet.RESPONSE, Buffer.concat([loginAck, ack, done]))) + } else socket.write(Packet.encode(Packet.RESPONSE, done)) + })) + }) + await new Promise((resolve, reject) => { + tlsServer.once("error", reject) + tlsServer.listen(0, "127.0.0.1", resolve) + }) + const server = Net.createServer((socket) => { + sockets.add(socket) + const state = { secure: false, applicationData: false } + const backend = Net.createConnection({ host: "127.0.0.1", port: (tlsServer.address() as Net.AddressInfo).port }) + sockets.add(backend) + backend.on("connect", () => states.set(backend.localPort!, state)) + backend.on("error", () => socket.destroy()) + socket.on("error", () => backend.destroy()) + socket.on("close", () => { + sockets.delete(socket) + backend.destroy() + }) + backend.on("close", () => { + sockets.delete(backend) + socket.destroy() + }) + backend.on("data", (data: Buffer) => + socket.write(state.applicationData ? data : Packet.encode(Packet.PRELOGIN, data))) + const packets = new Packet.PacketParser() + let prelogin = true + socket.on("data", (data: Buffer) => { + if (state.secure) { + backend.write(data) + return + } + packets.push(data, (packet) => { + if (prelogin) { + prelogin = false + expect(Packet.preloginOptions(packet.data).fedAuthRequired).toBe(true) + socket.write(Packet.encode(Packet.RESPONSE, Packet.prelogin(true, echo))) + } else backend.write(packet.data) + }) + }) + }) + try { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + } catch (error) { + tlsServer.close() + throw error + } + return { server, tlsServer, sockets } + }), + ({ server, tlsServer, sockets }) => + Effect.promise(async () => { + for (const socket of sockets) { + socket.destroy() + } + await Promise.all( + [server, tlsServer].map((server) => + new Promise((resolve) => server.close(() => resolve())) + ) + ) + }) + ).pipe(Effect.map(({ server }) => server)) +const settings = (server: Net.Server): Connection.Config => ({ + server: "127.0.0.1", + port: (server.address() as Net.AddressInfo).port, + encrypt: true, + trustServer: true, + accessToken: "test-token", + connectTimeoutMs: 2000 +}) + +describe("native TDS Security Token authentication", () => { + it.effect("obtains a token for each new pooled connection through the public adapter", () => + Effect.scoped(Effect.gen(function*() { + const server = yield* peer(Buffer.from([0xae, 2, 0, 0, 0, 0, 255])) + let refreshed = 0 + for (let i = 0; i < 2; i++) { + yield* Effect.scoped(MssqlClient.make({ + ...settings(server), + password: undefined, + authType: "azure-active-directory-access-token", + minConnections: 1, + maxConnections: 1, + accessToken: Effect.sync(() => { + refreshed++ + return Redacted.make("test-token") + }) + })) + } + expect(refreshed).toBe(2) + })).pipe(Effect.provide(Reactivity.layer))) + for (const echo of [false, true]) { + it.effect(`exchanges a token over TLS and echoes FEDAUTHREQUIRED=${echo}`, () => + Effect.scoped(Effect.gen(function*() { + const server = yield* peer(Buffer.from([0xae, 2, 0, 0, 0, 0, 255]), echo) + const session = yield* Connection.make(settings(server)) + expect((yield* session.query("SELECT 1")).rows).toEqual([]) + }))) + } + for ( + const [name, ack] of [ + ["missing", Buffer.alloc(0)], + ["unsolicited nonce", Buffer.from([0xae, 2, 1, 0, 0, 0, 0, 255])], + ["duplicate", Buffer.from([0xae, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 255])] + ] as const + ) { + it.effect(`rejects ${name} FedAuth acknowledgement`, () => + Effect.scoped(Effect.gen(function*() { + const server = yield* peer(ack) + const error = yield* Effect.flip(Connection.make(settings(server))) + expect(error.reason.cause).toBeInstanceOf(Packet.ProtocolError) + }))) + } + it("refuses to send tokens over plaintext or with NTLM", () => { + expect(() => new Connection.Session({ server: "localhost", accessToken: "secret", encrypt: false }, () => {})) + .toThrow("TLS") + expect(() => new Connection.Session({ server: "localhost", accessToken: "secret", authType: "ntlm" }, () => {})) + .toThrow("NTLM") + }) +}) diff --git a/packages/sql/mssql/test/TdsPacket.test.ts b/packages/sql/mssql/test/TdsPacket.test.ts index f35f49810ec..e696a9dfe74 100644 --- a/packages/sql/mssql/test/TdsPacket.test.ts +++ b/packages/sql/mssql/test/TdsPacket.test.ts @@ -3,6 +3,30 @@ import { describe, expect, it } from "@effect/vitest" import { Buffer } from "node:buffer" describe("TDS packets", () => { + it("encodes Security Token and UTF-8 feature extensions with byte lengths and indirect offsets", () => { + const data = Packet.login({ + server: "localhost", + username: "ignored", + password: "ignored", + accessToken: "token-λ", + fedAuthEcho: true + }) + expect(data[27] & 0x10).toBe(0x10) + expect(data[25] & 0x80).toBe(0) + expect(data.readUInt16LE(42)).toBe(0) + expect(data.readUInt16LE(46)).toBe(0) + expect(data.readUInt16LE(58)).toBe(4) + const offset = data.readUInt32LE(data.readUInt16LE(56)) + expect(data.subarray(offset, offset + 10)).toEqual(Buffer.from([2, 19, 0, 0, 0, 3, 14, 0, 0, 0])) + expect(data.toString("utf16le", offset + 10, offset + 24)).toBe("token-λ") + expect(data.subarray(offset + 24)).toEqual(Buffer.from([10, 1, 0, 0, 0, 1, 255])) + expect(data.readUInt32LE(0)).toBe(data.length) + expect(Packet.preloginOptions(Packet.prelogin(true, true))).toEqual({ encryption: 1, fedAuthRequired: true }) + expect(Packet.preloginOptions(Packet.prelogin(true))).toEqual({ encryption: 1, fedAuthRequired: false }) + expect(() => Packet.login({ server: "localhost", accessToken: "" })).toThrow("token") + expect(() => Packet.login({ server: "localhost", accessToken: "x".repeat(60001) })).toThrow("token") + expect(() => Packet.login({ server: "localhost", accessToken: "x", sspi: Buffer.from([1]) })).toThrow("SSPI") + }) it("decodes every two-chunk split including empty packets and multiple messages", () => { const payload = Buffer.alloc(1800, 0x5a) const wire = Buffer.concat([ @@ -20,7 +44,7 @@ describe("TDS packets", () => { expect(packets[4].type).toBe(Packet.ATTENTION) expect(packets[4].data.length).toBe(0) } - }) + }, 30000) it("handles one-byte fragments and packet id wraparound", () => { const payload = Buffer.alloc(504 * 257, 0x7b) diff --git a/packages/sql/mssql/test/TdsToken.test.ts b/packages/sql/mssql/test/TdsToken.test.ts index 035186e019f..5031035a3b2 100644 --- a/packages/sql/mssql/test/TdsToken.test.ts +++ b/packages/sql/mssql/test/TdsToken.test.ts @@ -14,6 +14,23 @@ const row = Buffer.from([0xd1, 42, 0, 0, 0, 255, 255, 255, 255]) const nbcRow = Buffer.from([0xd2, 1, 7, 0, 0, 0]) describe("TDS tokens", () => { + it("decodes fragmented feature acknowledgements and rejects duplicate IDs", () => { + const data = Buffer.from([0xae, 2, 0, 0, 0, 0, 10, 1, 0, 0, 0, 1, 255]) + for (let split = 0; split <= data.length; split++) { + const parser = new TokenParser() + const tokens: Array = [] + parser.push(data.subarray(0, split), (token) => tokens.push(token)) + parser.push(data.subarray(split), (token) => tokens.push(token)) + parser.end() + expect(tokens).toEqual([{ + _tag: "FeatureAck", + features: new Map([[2, Buffer.alloc(0)], [10, Buffer.from([1])]]) + }]) + } + expect(() => new TokenParser().push(Buffer.from([0xae, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 255]), () => {})).toThrow( + "Duplicate" + ) + }) it("handles every split through metadata, ROW, NBCROW, and DONE", () => { const data = Buffer.concat([metadata, row, nbcRow, done]) for (let i = 0; i <= data.length; i++) { diff --git a/packages/sql/mssql/test/fixtures/tls/README.md b/packages/sql/mssql/test/fixtures/tls/README.md new file mode 100644 index 00000000000..c086eda06fc --- /dev/null +++ b/packages/sql/mssql/test/fixtures/tls/README.md @@ -0,0 +1,13 @@ +# Disposable TLS test identity + +This is a public, non-production RSA key and self-signed localhost certificate +for the TDS TLS peer tests. Never use this key for a deployment. RSA is used +because the repository's Ed25519 fixtures cannot negotiate TLS 1.2 with Bun's +BoringSSL implementation. + +Generated with: + +```sh +openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \ + -days 36500 -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1 +``` diff --git a/packages/sql/mssql/test/fixtures/tls/cert.pem b/packages/sql/mssql/test/fixtures/tls/cert.pem new file mode 100644 index 00000000000..500a007b4e6 --- /dev/null +++ b/packages/sql/mssql/test/fixtures/tls/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUHe2i+2L12oh3LLlEEMUqqSRbV/gwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDkwODEzMTk0OVoYDzIxMjYw +ODE1MTMxOTQ5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDMBJ920VOwntYWs8DxjFUimYWXqZ8FysLEVTJAbS1U +X+XUSkjDbZ2QZfHlqmuwoskUkTX6gnMuQhsuLPOeotVozMj4NsAWtr4/V4x+WXxk +nbsnxJnKha7PFuOW3DwzOBWVQ6ttzskvuFBDgaJxzdQLD8B5BALzxyDv8LFjKOTc +UKYTskDhf3EAxp73tKLOt6L3EfOBde+bWHRubWtlBa2yzJQyOVveFOxD1oNnDQWJ +aoaUq2orrkg2se8Xm7dP46CLZ4tDxFhOU9GVaEI66q+1iVb3QN27yRvL+acflsig +7w+/5BBabb5mPuOssZzr/EzUcfuFPWEHpI/PmVX4N5oLAgMBAAGjbzBtMB0GA1Ud +DgQWBBRJx0orHVMVPo5rfkSIL5CHV5y1gzAfBgNVHSMEGDAWgBRJx0orHVMVPo5r +fkSIL5CHV5y1gzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAucy/03qThZWYMSWO26c6BN0IGaHl +mPW8MfCX9nvSHS6mOcIaFL4KhrArAOqUx3cKEFVRJRGAQXIhNVWV/6PtNHMJcU2L +YhpZuVW+3Ju5QQcMTRweoJx1syg2y3ZugtJrJFvL/U2K1Hn2Jgz3pMi2CretewjZ +Y7IOWBeLpNrhjwH1pwc6hYujXPNbnkI0W3g28bapoVdAStLfyVY45BLuhEAQkiMp +nUueh8/PnXhOZ7m1vFzO959g4/W/OUVnxa+trNybFa3Lo0p2xeK7WFhebR2LPcq1 +6Ulk6zNhXfvXJD0nQ9IBHwTQqiA0VS0S4/pKSCd0Wk5lluPkYlWKD8a3xg== +-----END CERTIFICATE----- diff --git a/packages/sql/mssql/test/fixtures/tls/key.pem b/packages/sql/mssql/test/fixtures/tls/key.pem new file mode 100644 index 00000000000..3761f876423 --- /dev/null +++ b/packages/sql/mssql/test/fixtures/tls/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMBJ920VOwntYW +s8DxjFUimYWXqZ8FysLEVTJAbS1UX+XUSkjDbZ2QZfHlqmuwoskUkTX6gnMuQhsu +LPOeotVozMj4NsAWtr4/V4x+WXxknbsnxJnKha7PFuOW3DwzOBWVQ6ttzskvuFBD +gaJxzdQLD8B5BALzxyDv8LFjKOTcUKYTskDhf3EAxp73tKLOt6L3EfOBde+bWHRu +bWtlBa2yzJQyOVveFOxD1oNnDQWJaoaUq2orrkg2se8Xm7dP46CLZ4tDxFhOU9GV +aEI66q+1iVb3QN27yRvL+acflsig7w+/5BBabb5mPuOssZzr/EzUcfuFPWEHpI/P +mVX4N5oLAgMBAAECggEACI0tDfLqOh3+RUdVkApcyy16Lwg6FSkRjt8vUwoAe2fl +n0dZ0281yoarZAAHsYixOv53L7/mCgtVTlMuBvk2TsU+q0DIPmt8f/Uwe19O2nzO +CnWwKI//6sg/0+P5VgBEz1PdzKV9Q1XLb3jwkSqad7g6Thg/823mUIhbNxfQt/dK +Ri6YfKVwJ/Swy36yVTfTvurARtLMC22GuF3pvED8OwwGFpfjTGyt9kmN1Ihrl9ds +E+YSlBZiOpx62+iOup9y4gkvm124zA+uv8Wr8eBhtYqD+fESqY6OgYmvfYF/e6eP +8+/ANuNyqvg//fNNcQtOPmAiSslGvgqOTeUcsZSJSQKBgQDlKNZo6OzbH9X41h7f +9jgKTtsv89WEmN92WWhN1lzOn0Mje+7nAqPLINv86ekjPY1PxTNKF5X8HuGAKPGJ +gYGD/S/SPfm/Pq63Jsg3Z4w5lTlhTQjRJzb56Lw1g4xAL1II3nq0Y0kz28leulMt +cnW1QdYM0n01TnOk++M7GOF1lQKBgQDj6fBgeb6XPWa/RqE9ZuG7QWBPSd5nQtDE +IFSZZQdS0YhnDkRQ2xI5LvPN9Z1lNR+q/d15b21aQj5zLtXrh4pS13XqXP0vrjbH +6hDsxD3KGZTfuNh4cExrINKMnheslptK9TIs1/Rm5wHHGEVUgn7saFuRO8ix+/t8 +Wg5ljF+pHwKBgD8VO9jLbhBwNh33C67Jf+nOhWYw8HE9CB7zQ9RRk2DJ1Nia0ExF +nMNZO1XnKmeqFRGFjrXxwjcSZS87liotPXMRKq3so6iy37PJeEpQNfjQDGitpSS2 +b9rh55Wvrdcpb6LDY/HudwNQmKF8F1Omc6K/CeajtxYKj6k2Mfpdwgr1AoGBAI/A +ZhTztPq/h6btbZXE5muteImKFfUbd4G8wp9d0ZizAE2/+eblxkC7FJu7Z2MGdsrr +BF4rodVkIu+Tgunju6M0svL3Y9pHD9ZYFATCYhauu+/ALwkIC6smMKbwsA3zkE9f +CB6wtz6QRdbFwDTlehiyWc7o3lUkkiKEyr/SIiQRAoGAZIM5Wgh6Kn5e9ps4f23J +4V8RmD4BhWlHcLKKmb5hVYwzAMNbdjEPknIo5/1fcTOxmkfd/e4jhzhUEQ3PPLxp +xdC8rEcH3Kz+SHwD3B3bBpIkqcZBtTD9nm7VEFIC2U4/+9wTs1JS1Zns7PpY47tj +7PDw4HAr7lbGj9yFXk6oMIs= +-----END PRIVATE KEY----- From a8ce97f59bbe31b52b0366ecde6c14d43b91163e Mon Sep 17 00:00:00 2001 From: Joep Joosten Date: Tue, 8 Sep 2026 15:42:52 +0200 Subject: [PATCH 3/4] Fix native TDS TLS framing across runtimes and preserve temporal precision --- packages/sql/mssql/README.md | 5 ++ packages/sql/mssql/benchmark/README.md | 21 +++--- packages/sql/mssql/benchmark/TdsClient.ts | 12 ++- packages/sql/mssql/benchmark/results.jsonl | 13 ++-- packages/sql/mssql/src/MssqlTypes.ts | 13 ++++ .../sql/mssql/src/internal/tdsConnection.ts | 74 +++++++++++++++++-- packages/sql/mssql/src/internal/tdsRequest.ts | 17 ++++- packages/sql/mssql/src/internal/tdsToken.ts | 8 +- .../test/TdsConnection.integration.test.ts | 30 ++++++++ .../mssql/test/TdsFedAuth.in-process.test.ts | 40 +++++++--- packages/sql/mssql/test/TdsPacket.test.ts | 4 +- packages/sql/mssql/test/TdsRequest.test.ts | 14 ++++ 12 files changed, 210 insertions(+), 41 deletions(-) diff --git a/packages/sql/mssql/README.md b/packages/sql/mssql/README.md index 74075c60478..74d995279ef 100644 --- a/packages/sql/mssql/README.md +++ b/packages/sql/mssql/README.md @@ -32,6 +32,11 @@ Use `MssqlTypes` from `@effect/sql-mssql` for procedure parameter descriptors an `parameterTypes`. These are native descriptors, not the objects exported by tedious. SQL `bigint` results remain strings; decimal and numeric results remain JavaScript numbers, which can lose precision. Exact decimal input can be supplied as a string. +`time`, `datetime2`, and `datetimeoffset` results retain sub-millisecond precision +in a non-enumerable `nanosecondsDelta` property, compatible with tedious. Despite +the property's name, it is measured in seconds. Passing the Date back as a native +temporal parameter preserves the fraction at the requested scale. Lower scales +round values, including rollover at midnight. For Azure SQL, provide `accessToken` as an Effect returning a redacted access token for the SQL service. It runs for each new pooled connection, allowing the diff --git a/packages/sql/mssql/benchmark/README.md b/packages/sql/mssql/benchmark/README.md index d898aa55344..518a01a0b04 100644 --- a/packages/sql/mssql/benchmark/README.md +++ b/packages/sql/mssql/benchmark/README.md @@ -56,15 +56,18 @@ changes, not the ratio of independent medians. | Workload | Native operations/s | Tedious operations/s | Paired change | | ------------------------- | ------------------: | -------------------: | ------------: | -| Parameterized SELECT | 2,265 | 2,044 | +10.8% | -| 100 rows × 3 columns | 1,186 | 1,062 | +9.3% | -| 100 rows × 20 columns | 1,057 | 966 | +11.6% | -| Large Unicode result | 856 | 919 | −0.7% | -| Begin / insert / rollback | 351 | 319 | +11.0% | -| DONEPROC tokens | 9,037,162 | 4,951,068 | +83.5% | - -Samples contain substantial outliers. A codec run briefly overlapped the tail -of the live run, and unrelated host activity was not controlled. Treat these +| Parameterized SELECT | 2,202 | 1,963 | +12.2% | +| 100 rows × 3 columns | 1,114 | 968 | +19.6% | +| 100 rows × 20 columns | 1,065 | 966 | +10.2% | +| Large Unicode result | 1,107 | 1,031 | +7.2% | +| Large Unicode parameter | 1,085 | 982 | +10.4% | +| Begin / insert / rollback | 358 | 316 | +11.3% | +| DONEPROC tokens | 9,540,062 | 5,086,195 | +87.2% | + +These samples were rerun after the TLS write-queue changes, with the live and +codec benchmarks run sequentially. The large parameter sends and returns 10,000 +Unicode characters, exercising multi-packet requests. Unrelated host activity +was not controlled, and samples vary materially. Treat these numbers as directional evidence and rerun longer, isolated trials before making release claims. No latency percentiles, memory/GC measurements, remote-server results, or concurrent pool load are established by this harness. diff --git a/packages/sql/mssql/benchmark/TdsClient.ts b/packages/sql/mssql/benchmark/TdsClient.ts index d170da85bbb..64f9b62a4f8 100644 --- a/packages/sql/mssql/benchmark/TdsClient.ts +++ b/packages/sql/mssql/benchmark/TdsClient.ts @@ -38,7 +38,7 @@ const baseline = Effect.acquireRelease( (conn) => Effect.sync(() => conn.close()) ) -const tediousQuery = (conn: Tedious.Connection, query: string, parameter: boolean) => +const tediousQuery = (conn: Tedious.Connection, query: string, parameter: boolean | string) => Effect.callback, Error>((resume) => { const request = new Tedious.Request(query, (error, _count, rows) => { if (error) { @@ -62,7 +62,8 @@ const tediousQuery = (conn: Tedious.Connection, query: string, parameter: boolea return row }))) }) - if (parameter) request.addParameter("value", Tedious.TYPES.Float, 42) + if (typeof parameter === "string") request.addParameter("value", Tedious.TYPES.NVarChar, parameter) + else if (parameter) request.addParameter("value", Tedious.TYPES.Float, 42) conn.execSql(request) return Effect.sync(() => { conn.cancel() @@ -115,6 +116,7 @@ const program = Effect.scoped(Effect.gen(function*() { parameter: false }, { name: "large-unicode", sql: "SELECT REPLICATE(CAST(N'λ' AS nvarchar(max)), 10000) AS text", parameter: false }, + { name: "large-unicode-parameter", sql: "SELECT @value AS text", parameter: "λ".repeat(10000) }, { name: "transaction-insert-rollback", sql: "DECLARE @t TABLE(value float); INSERT INTO @t VALUES(@value)", @@ -124,7 +126,11 @@ const program = Effect.scoped(Effect.gen(function*() { for (const workload of workloads) { const nativeQuery = native.query( workload.sql, - workload.parameter ? [{ name: "value", type: TYPES.Float, value: 42 }] : [] + typeof workload.parameter === "string" + ? [{ name: "value", type: TYPES.NVarChar, value: workload.parameter }] + : workload.parameter + ? [{ name: "value", type: TYPES.Float, value: 42 }] + : [] ) .pipe(Effect.map((result) => result.rows)) const baselineQuery = tediousQuery(tedious, workload.sql, workload.parameter) diff --git a/packages/sql/mssql/benchmark/results.jsonl b/packages/sql/mssql/benchmark/results.jsonl index 0c3d1502445..90468312231 100644 --- a/packages/sql/mssql/benchmark/results.jsonl +++ b/packages/sql/mssql/benchmark/results.jsonl @@ -1,6 +1,7 @@ -{"workload":"parameterized-select","nativeQueriesPerSecond":2265.2614731987064,"tediousQueriesPerSecond":2043.5683520431644,"medianPairedDeltaPercent":10.84833403951928,"nativeRates":[1294.7784488760708,2195.8242140244884,2265.2614731987064,2310.6830400654126,2419.094855341625],"tediousRates":[1678.9299619709038,2005.9189028926053,2043.5683520431644,2063.1820611000603,2090.4983815469495]} -{"workload":"100-rows-3-columns","nativeQueriesPerSecond":1186.2084612254866,"tediousQueriesPerSecond":1061.6600954058379,"medianPairedDeltaPercent":9.331439883834891,"nativeRates":[1120.2718145475224,1164.065406906648,1266.4828848022862,1268.8267195842766,1186.2084612254866],"tediousRates":[1061.6600954058379,1037.4460532879934,1158.3885533273224,1187.2882207116845,956.4242520817306]} -{"workload":"100-rows-20-columns","nativeQueriesPerSecond":1057.4548234390074,"tediousQueriesPerSecond":966.4625278742973,"medianPairedDeltaPercent":11.551534344184233,"nativeRates":[1088.376711153324,966.434937474521,950.3656123822867,1057.4548234390074,1091.959111227097],"tediousRates":[982.2087936673535,966.4625278742973,429.49650344303546,666.7676115640329,978.8830943893033]} -{"workload":"large-unicode","nativeQueriesPerSecond":855.8934412665619,"tediousQueriesPerSecond":918.9355149015155,"medianPairedDeltaPercent":-0.6830727590042995,"nativeRates":[1157.8768017084358,983.4798017336628,821.667792520227,855.8934412665619,804.1818789130449],"tediousRates":[1069.9053635304724,990.2438879801604,916.541084995899,918.9355149015155,800.6786450945255]} -{"workload":"transaction-insert-rollback","nativeQueriesPerSecond":350.56131599060745,"tediousQueriesPerSecond":318.9328328809418,"medianPairedDeltaPercent":11.007934004154718,"nativeRates":[320.49403040371834,349.25801002250546,350.56131599060745,361.91099650596277,353.45551223823895],"tediousRates":[379.1178140772397,314.62436730822657,335.9967757908749,318.9328328809418,206.5061081675671]} -{"workload":"doneproc","node":"v24.20.0","tokenCount":10000,"repeats":100,"fragment":130000,"rounds":7,"nativeTokensPerSecond":9037161.966764053,"tediousTokensPerSecond":4951067.976306658,"medianPairedDeltaPercent":83.47851475575712,"nativeRates":[8868642.133643469,9281941.315798532,9099470.865769159,8554280.270146217,9037161.966764053,9219107.060337914,8757618.723249363],"tediousRates":[4891930.145331272,5009275.500118927,4135448.339462411,4254080.632193138,4981255.957239664,5024624.8573628375,4951067.976306658]} +{"workload":"parameterized-select","nativeQueriesPerSecond":2201.997870001595,"tediousQueriesPerSecond":1962.5415010031766,"medianPairedDeltaPercent":12.201340398458704,"nativeRates":[2201.997870001595,2154.0196221483825,2319.9279735519876,2271.6738875833935,2191.3620711265667],"tediousRates":[1962.5415010031766,1970.2576175421743,1909.7551838052193,1841.7386342140987,2025.329844365124]} +{"workload":"100-rows-3-columns","nativeQueriesPerSecond":1114.4609252922753,"tediousQueriesPerSecond":967.9035243080797,"medianPairedDeltaPercent":19.623055213822195,"nativeRates":[1093.1320760580559,1104.706840827416,1159.3582951836165,1168.9091502296267,1114.4609252922753],"tediousRates":[913.8138748455464,907.4202093017893,967.9035243080797,1036.4634516695676,1174.4411082051922]} +{"workload":"100-rows-20-columns","nativeQueriesPerSecond":1065.1231490319228,"tediousQueriesPerSecond":966.3957009088809,"medianPairedDeltaPercent":10.216047942906847,"nativeRates":[1086.029771023628,1058.8708906735756,998.9228282169059,1100.7141214676315,1065.1231490319228],"tediousRates":[983.1262197001222,984.9023846986934,940.9212098434224,928.1018967999322,966.3957009088809]} +{"workload":"large-unicode","nativeQueriesPerSecond":1106.9920232472725,"tediousQueriesPerSecond":1030.522621588654,"medianPairedDeltaPercent":7.1804515303668826,"nativeRates":[1146.3201600655384,1106.9920232472725,1147.7769486796403,1087.1656004016897,1030.3241992667206],"tediousRates":[1069.5235406250902,1030.522621588654,987.5263165434984,1073.9700181149174,980.5594535865513]} +{"workload":"large-unicode-parameter","nativeQueriesPerSecond":1084.5191602173215,"tediousQueriesPerSecond":981.9275475446053,"medianPairedDeltaPercent":10.447981923845129,"nativeRates":[976.3431327346169,1022.3680206993988,1084.5191602173215,1099.0375493718789,1107.574873625638],"tediousRates":[991.3213863188269,989.9408750151388,981.9275475446053,975.2844662965601,963.2811790342049]} +{"workload":"transaction-insert-rollback","nativeQueriesPerSecond":358.44813845282783,"tediousQueriesPerSecond":316.485310942663,"medianPairedDeltaPercent":11.348344435291736,"nativeRates":[320.02763848984335,335.12587999634155,358.532070271415,358.44813845282783,364.7765020030266],"tediousRates":[293.8406480865289,288.9338179940251,321.99137947647716,331.51061651366103,316.485310942663]} +{"workload":"doneproc","node":"v24.20.0","tokenCount":10000,"repeats":100,"fragment":130000,"rounds":7,"nativeTokensPerSecond":9540061.700349042,"tediousTokensPerSecond":5086195.11280227,"medianPairedDeltaPercent":87.22673220689055,"nativeRates":[9131766.773873,9540061.700349042,9566707.917673435,9661785.152777268,9376095.127910947,9642089.587759094,9512877.234692613],"tediousRates":[5035649.249405006,5095459.172895791,5049608.209483363,5086195.11280227,5084905.204654723,5109302.882269644,5125490.142934139]} diff --git a/packages/sql/mssql/src/MssqlTypes.ts b/packages/sql/mssql/src/MssqlTypes.ts index b862e5ea6d4..6b65188bd83 100644 --- a/packages/sql/mssql/src/MssqlTypes.ts +++ b/packages/sql/mssql/src/MssqlTypes.ts @@ -34,6 +34,19 @@ export type ParameterOptions = internal.ParameterOptions */ export type Table = internal.Table +/** + * A decoded Time, DateTime2 or DateTimeOffset value retaining its sub-millisecond + * fraction. For compatibility, `nanosecondsDelta` is a fraction of a second, + * despite its name, and is non-enumerable. Passing the value back as a temporal + * parameter preserves that fraction at the parameter's declared scale. + * + * @category models + * @since 4.0.0 + */ +export interface DateWithNanosecondsDelta extends globalThis.Date { + readonly nanosecondsDelta: number +} + /** * The SQL Server TinyInt parameter type. * diff --git a/packages/sql/mssql/src/internal/tdsConnection.ts b/packages/sql/mssql/src/internal/tdsConnection.ts index 01a933e3843..80d69b6fa4d 100644 --- a/packages/sql/mssql/src/internal/tdsConnection.ts +++ b/packages/sql/mssql/src/internal/tdsConnection.ts @@ -98,6 +98,8 @@ export class Session { private packetSize: number private readonly closedListeners = new Set<() => void>() private readonly semaphore = Semaphore.makeUnsafe(1) + private readonly writes: Array = [] + private writing = false readonly config: Config private readonly connected: (result: Effect.Effect) => void @@ -252,17 +254,46 @@ export class Session { } private write(data: Buffer): void { - try { - ;(this.tls ?? this.socket).write(data) - } catch (error) { - this.fail(error) + if (this.closed) return + this.writes.push(data) + if (this.writing) return + this.writing = true + const drain = (): void => { + const data = this.writes.shift() + if (data === undefined || this.closed) { + this.writing = false + return + } + let offset = 0 + const next = (): void => { + if (this.closed) return + // Some Node-compatible runtimes do not implement setMaxSendFragment. + // Await each TLS write so _writev cannot combine packets into a record + // larger than SQL Server's negotiated receive size. Queue whole messages + // so cancellation cannot insert ATTENTION inside an unfinished request. + const end = this.tls ? Math.min(offset + this.packetSize, data.length) : data.length + const chunk = data.subarray(offset, end) + offset = end + try { + ;(this.tls ?? this.socket).write(chunk, (error) => { + if (error) this.fail(error) + else if (offset < data.length) next() + else drain() + }) + } catch (error) { + this.fail(error) + } + } + next() } + drain() } private fail(cause: unknown): void { if (this.closed) return const connecting = this.state !== "ready" this.state = "closed" + this.writes.length = 0 this.loginPayload.fill(0) clearTimeout(this.connectTimer) this.tls?.destroy() @@ -358,13 +389,40 @@ export class Session { private startTls(): void { this.state = "handshake" + let buffered: Buffer = Buffer.alloc(0) + let applicationData = false this.bridge = new Duplex({ read() {}, write: (chunk: Buffer, _encoding, callback) => { - this.socket.write( - this.state === "handshake" ? Packet.encode(Packet.PRELOGIN, chunk, this.packetSize) : chunk, - callback - ) + if (applicationData && buffered.length === 0) { + this.socket.write(chunk, callback) + return + } + // secureConnect can precede the final outgoing handshake records on + // resumed sessions in Node-compatible runtimes. Frame complete TLS + // records, retaining the TDS wrapper until application traffic begins. + buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]) + const output: Array = [] + const handshake: Array = [] + const flushHandshake = () => { + if (handshake.length === 0) return + output.push(Packet.encode(Packet.PRELOGIN, Buffer.concat(handshake), this.packetSize)) + handshake.length = 0 + } + while (buffered.length >= 5) { + const length = 5 + buffered.readUInt16BE(3) + if (buffered.length < length) break + const record = buffered.subarray(0, length) + buffered = buffered.subarray(length) + if (this.state !== "handshake" && record[0] === 23) applicationData = true + if (applicationData) { + flushHandshake() + output.push(record) + } else handshake.push(record) + } + flushHandshake() + if (output.length === 0) callback() + else this.socket.write(Buffer.concat(output), callback) } }) this.bridge.on("error", (error) => this.fail(error)) diff --git a/packages/sql/mssql/src/internal/tdsRequest.ts b/packages/sql/mssql/src/internal/tdsRequest.ts index efb5e221335..d9fafe4b6aa 100644 --- a/packages/sql/mssql/src/internal/tdsRequest.ts +++ b/packages/sql/mssql/src/internal/tdsRequest.ts @@ -278,12 +278,25 @@ const encodeValue = (p: Parameter, collation: Buffer): { info: Buffer; body: Buf if (!isNull) { const d = date(value) if (d.getUTCFullYear() < 1 || d.getUTCFullYear() > 9999) throw new ProtocolError("Date outside SQL Server range") - const days = Math.floor((d.getTime() + 62135596800000) / 86400000) + let days = Math.floor((d.getTime() + 62135596800000) / 86400000) const time = ((d.getTime() % 86400000) + 86400000) % 86400000 const parts: Array = [] if (name !== "Date") { + // The result decoder uses tedious's plural spelling. Accept the legacy + // singular spelling too, which tedious's input encoder used. + const temporal = d as Date & { nanosecondsDelta?: unknown; nanosecondDelta?: unknown } + const delta = temporal.nanosecondsDelta ?? temporal.nanosecondDelta ?? 0 + if (typeof delta !== "number" || !Number.isFinite(delta) || delta < 0 || delta >= 0.001) { + throw new ProtocolError("Invalid sub-millisecond time fraction") + } + let ticks = Math.round(time * 10 ** (scale - 3) + delta * 10 ** scale) + if (ticks === 86400 * 10 ** scale) { + ticks = 0 + if (name !== "Time") days++ + } + if (days > 3652058) throw new ProtocolError("Rounded date outside SQL Server range") const t = Buffer.alloc(timeSize) - t.writeUIntLE(Math.floor(time * 10 ** scale / 1000), 0, timeSize) + t.writeUIntLE(ticks, 0, timeSize) parts.push(t) } if (name !== "Time") { diff --git a/packages/sql/mssql/src/internal/tdsToken.ts b/packages/sql/mssql/src/internal/tdsToken.ts index 329a72606f1..acdafd04b36 100644 --- a/packages/sql/mssql/src/internal/tdsToken.ts +++ b/packages/sql/mssql/src/internal/tdsToken.ts @@ -295,10 +295,14 @@ export const value = (r: Reader, c: Column, maxValueSize: number): unknown => { case 0x2b: { const timeLength = c.scale <= 2 ? 3 : c.scale <= 4 ? 4 : 5 sized(data, [timeLength + (type === 0x29 ? 0 : type === 0x2a ? 3 : 5)]) - const time = data.readUIntLE(0, timeLength) / 10 ** c.scale * 1000 + const ticks = data.readUIntLE(0, timeLength) * 10 ** (7 - c.scale) + if (ticks >= 864000000000) throw new ProtocolError("Time outside SQL Server range") const days = type === 0x29 ? 0 : data.readUIntLE(timeLength, 3) + if (days > 3652058) throw new ProtocolError("Date outside SQL Server range") // DATETIMEOFFSET's date/time fields are already UTC on the wire. - return new Date((type === 0x29 ? 0 : dateEpoch) + days * 86400000 + Math.floor(time)) + const date = new Date((type === 0x29 ? 0 : dateEpoch) + days * 86400000 + Math.floor(ticks / 10000)) + Object.defineProperty(date, "nanosecondsDelta", { value: (ticks % 10000) / 1e7, enumerable: false }) + return date } case 0xa7: case 0xaf: diff --git a/packages/sql/mssql/test/TdsConnection.integration.test.ts b/packages/sql/mssql/test/TdsConnection.integration.test.ts index 772a29e07fd..308c9a90dc2 100644 --- a/packages/sql/mssql/test/TdsConnection.integration.test.ts +++ b/packages/sql/mssql/test/TdsConnection.integration.test.ts @@ -36,6 +36,36 @@ afterAll(async () => { }, 60000) describe("native TDS / SQL Server", () => { + it.effect("preserves 100ns temporal fractions and rounds reduced scales across midnight", () => + Effect.scoped(Effect.gen(function*() { + const session = yield* Connection.make(config) + const result = yield* session.query(`SELECT + CAST('12:34:56.1234567' AS time(7)) AS t, + CAST('2024-02-29T12:34:56.1234567' AS datetime2(7)) AS dt, + CAST('2024-02-29T12:34:56.1234567+02:00' AS datetimeoffset(7)) AS dto`) + const row = result.rows[0] as Record + expect(row.t.toISOString()).toBe("1970-01-01T12:34:56.123Z") + expect(row.dt.toISOString()).toBe("2024-02-29T12:34:56.123Z") + expect(row.dto.toISOString()).toBe("2024-02-29T10:34:56.123Z") + for (const [name, type] of [["t", TYPES.Time], ["dt", TYPES.DateTime2], ["dto", TYPES.DateTimeOffset]] as const) { + expect(row[name].nanosecondsDelta).toBe(0.0004567) + expect(Object.keys(row[name])).not.toContain("nanosecondsDelta") + const roundtrip = yield* session.query("SELECT DATEPART(NANOSECOND, @value) AS fraction", [{ + name: "value", + type, + value: row[name], + options: { scale: 7 } + }]) + expect(roundtrip.rows).toEqual([{ fraction: 123456700 }]) + } + const rounded = yield* session.query("SELECT @value AS value", [{ + name: "value", + type: TYPES.DateTime2, + value: new Date("2024-02-29T23:59:59.999Z"), + options: { scale: 0 } + }]) + expect(rounded.rows[0].value.toISOString()).toBe("2024-03-01T00:00:00.000Z") + }))) it.effect("roundtrips decimal, money, legacy LOB, XML, and ANSI parameters", () => Effect.scoped(Effect.gen(function*() { const session = yield* Connection.make(config) diff --git a/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts b/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts index 1e3770fd0c8..bed4939e153 100644 --- a/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts +++ b/packages/sql/mssql/test/TdsFedAuth.in-process.test.ts @@ -24,6 +24,8 @@ const peer = (ack: Buffer, echo = true) => maxVersion: "TLSv1.2", requestCert: false }, (socket) => { + sockets.add(socket) + socket.on("close", () => sockets.delete(socket)) const state = states.get(socket.remotePort!)! state.secure = true const packets = new Packet.PacketParser() @@ -44,6 +46,10 @@ const peer = (ack: Buffer, echo = true) => } else socket.write(Packet.encode(Packet.RESPONSE, done)) })) }) + tlsServer.on("connection", (socket) => { + sockets.add(socket) + socket.on("close", () => sockets.delete(socket)) + }) await new Promise((resolve, reject) => { tlsServer.once("error", reject) tlsServer.listen(0, "127.0.0.1", resolve) @@ -68,18 +74,32 @@ const peer = (ack: Buffer, echo = true) => socket.write(state.applicationData ? data : Packet.encode(Packet.PRELOGIN, data))) const packets = new Packet.PacketParser() let prelogin = true + let incoming = Buffer.alloc(0) socket.on("data", (data: Buffer) => { - if (state.secure) { - backend.write(data) - return + incoming = Buffer.concat([incoming, data]) + // A resumed client's final wrapped handshake and first raw TLS + // application record may share a TCP read, before secure fires here. + while (incoming.length >= 5) { + const wrapped = incoming[0] === Packet.PRELOGIN + const length = wrapped ? incoming.readUInt16BE(2) : 5 + incoming.readUInt16BE(3) + if (incoming.length < length) { + return + } + const record = incoming.subarray(0, length) + incoming = incoming.subarray(length) + if (!wrapped) { + expect(record[0]).toBe(23) + backend.write(record) + continue + } + packets.push(record, (packet) => { + if (prelogin) { + prelogin = false + expect(Packet.preloginOptions(packet.data).fedAuthRequired).toBe(true) + socket.write(Packet.encode(Packet.RESPONSE, Packet.prelogin(true, echo))) + } else backend.write(packet.data) + }) } - packets.push(data, (packet) => { - if (prelogin) { - prelogin = false - expect(Packet.preloginOptions(packet.data).fedAuthRequired).toBe(true) - socket.write(Packet.encode(Packet.RESPONSE, Packet.prelogin(true, echo))) - } else backend.write(packet.data) - }) }) }) try { diff --git a/packages/sql/mssql/test/TdsPacket.test.ts b/packages/sql/mssql/test/TdsPacket.test.ts index e696a9dfe74..d974ced69c8 100644 --- a/packages/sql/mssql/test/TdsPacket.test.ts +++ b/packages/sql/mssql/test/TdsPacket.test.ts @@ -2,7 +2,9 @@ import * as Packet from "#tds/tdsPacket" import { describe, expect, it } from "@effect/vitest" import { Buffer } from "node:buffer" -describe("TDS packets", () => { +// Exhaustive synchronous fragmentation loops must not starve concurrently +// scheduled tests in the same worker (notably on busy Bun CI runners). +describe("TDS packets", { concurrent: false }, () => { it("encodes Security Token and UTF-8 feature extensions with byte lengths and indirect offsets", () => { const data = Packet.login({ server: "localhost", diff --git a/packages/sql/mssql/test/TdsRequest.test.ts b/packages/sql/mssql/test/TdsRequest.test.ts index b4e79dcb66d..875d91f3a71 100644 --- a/packages/sql/mssql/test/TdsRequest.test.ts +++ b/packages/sql/mssql/test/TdsRequest.test.ts @@ -45,4 +45,18 @@ describe("TDS request encoding", () => { expect(() => parameter(Request.TYPES.TVP, value)).toThrow("does not match") expect(() => parameter(Request.TYPES.TVP, { ...value, rows: [["invalid"]] })).toThrow("integer") }) + + it("rounds temporal scales, carries midnight, and preserves sub-millisecond fractions", () => { + for (const type of [Request.TYPES.Time, Request.TYPES.DateTime2, Request.TYPES.DateTimeOffset]) { + expect(parameter(type, new Date("2024-01-01T23:59:59.999Z"), { scale: 0 })) + .toEqual(parameter(type, new Date("2024-01-02T00:00:00Z"), { scale: 0 })) + const value = Object.assign(new Date("2024-01-01T00:00:00.123Z"), { nanosecondsDelta: 0.0004567 }) + const bytes = parameter(type, value, { scale: 7 }) + const timeOffset = bytes.length - (type.name === "Time" ? 5 : type.name === "DateTime2" ? 8 : 10) + expect(bytes.readUIntLE(timeOffset, 5)).toBe(1234567) + expect(() => parameter(type, Object.assign(new Date(), { nanosecondsDelta: NaN }))).toThrow("fraction") + } + expect(() => parameter(Request.TYPES.DateTime2, new Date("9999-12-31T23:59:59.999Z"), { scale: 0 })) + .toThrow("Rounded date") + }) }) From 73abe749ea202dbdf91032d08d2e6e3ba576b672 Mon Sep 17 00:00:00 2001 From: Joep Joosten Date: Tue, 8 Sep 2026 16:15:32 +0200 Subject: [PATCH 4/4] Replace js-md4 dependency with private native MD4 implementation --- packages/sql/mssql/package.json | 3 +- packages/sql/mssql/src/internal/js-md4.d.ts | 6 -- packages/sql/mssql/src/internal/md4.ts | 65 +++++++++++++++++++++ packages/sql/mssql/src/internal/tdsNtlm.ts | 4 +- packages/sql/mssql/test/Md4.test.ts | 55 +++++++++++++++++ pnpm-lock.yaml | 3 - 6 files changed, 123 insertions(+), 13 deletions(-) delete mode 100644 packages/sql/mssql/src/internal/js-md4.d.ts create mode 100644 packages/sql/mssql/src/internal/md4.ts create mode 100644 packages/sql/mssql/test/Md4.test.ts diff --git a/packages/sql/mssql/package.json b/packages/sql/mssql/package.json index 5c2503aaabd..23014e351f5 100644 --- a/packages/sql/mssql/package.json +++ b/packages/sql/mssql/package.json @@ -72,7 +72,6 @@ "effect": "workspace:^" }, "dependencies": { - "iconv-lite": "^0.7.0", - "js-md4": "^0.3.2" + "iconv-lite": "^0.7.0" } } diff --git a/packages/sql/mssql/src/internal/js-md4.d.ts b/packages/sql/mssql/src/internal/js-md4.d.ts deleted file mode 100644 index bfbddfe0b2d..00000000000 --- a/packages/sql/mssql/src/internal/js-md4.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module "js-md4" { - const md4: { - readonly arrayBuffer: (data: Uint8Array) => ArrayBuffer - } - export default md4 -} diff --git a/packages/sql/mssql/src/internal/md4.ts b/packages/sql/mssql/src/internal/md4.ts new file mode 100644 index 00000000000..c49e753d6b2 --- /dev/null +++ b/packages/sql/mssql/src/internal/md4.ts @@ -0,0 +1,65 @@ +// RFC 1320, section 3. MD4 is required by NTLM's password hash; it must not +// be used as a general-purpose cryptographic hash. This implementation only +// accepts bytes: the NTLM caller is responsible for UTF-16LE encoding. +const shifts = [3, 7, 11, 19, 3, 5, 9, 13, 3, 9, 11, 15] +const round3 = [0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15] + +export const md4 = (input: Uint8Array): Uint8Array => { + const padded = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64) + padded.set(input) + padded[input.length] = 0x80 + const data = new DataView(padded.buffer) + data.setUint32(padded.length - 8, input.length * 8, true) + data.setUint32(padded.length - 4, Math.floor(input.length / 0x20000000), true) + + let h0 = 0x67452301 + let h1 = 0xefcdab89 + let h2 = 0x98badcfe + let h3 = 0x10325476 + for (let offset = 0; offset < padded.length; offset += 64) { + let a = h0 + let b = h1 + let c = h2 + let d = h3 + for (let step = 0; step < 48; step++) { + const i = step & 15 + let f: number + let k: number + let constant: number + let shift: number + if (step < 16) { + f = (b & c) | (~b & d) + k = i + constant = 0 + shift = shifts[i & 3] + } else if (step < 32) { + f = (b & c) | (b & d) | (c & d) + k = (i & 3) * 4 + (i >>> 2) + constant = 0x5a827999 + shift = shifts[4 + (i & 3)] + } else { + f = b ^ c ^ d + k = round3[i] + constant = 0x6ed9eba1 + shift = shifts[8 + (i & 3)] + } + const sum = (a + f + data.getUint32(offset + k * 4, true) + constant) | 0 + a = d + d = c + c = b + b = (sum << shift) | (sum >>> (32 - shift)) + } + h0 = (h0 + a) | 0 + h1 = (h1 + b) | 0 + h2 = (h2 + c) | 0 + h3 = (h3 + d) | 0 + } + padded.fill(0) + const output = new Uint8Array(16) + const digest = new DataView(output.buffer) + digest.setUint32(0, h0, true) + digest.setUint32(4, h1, true) + digest.setUint32(8, h2, true) + digest.setUint32(12, h3, true) + return output +} diff --git a/packages/sql/mssql/src/internal/tdsNtlm.ts b/packages/sql/mssql/src/internal/tdsNtlm.ts index ef68dcd55d2..c4ef3fa4541 100644 --- a/packages/sql/mssql/src/internal/tdsNtlm.ts +++ b/packages/sql/mssql/src/internal/tdsNtlm.ts @@ -1,6 +1,6 @@ -import md4 from "js-md4" import { Buffer } from "node:buffer" import { createHmac, randomBytes } from "node:crypto" +import { md4 } from "./md4.ts" import { ProtocolError } from "./tdsPacket.ts" const signature = Buffer.from("NTLMSSP\0", "ascii") @@ -10,7 +10,7 @@ const hmac = (key: Buffer, data: Buffer): Buffer => createHmac("md5", key).updat export const responseKey = (username: string, domain: string, password: string): Buffer => hmac( - Buffer.from(md4.arrayBuffer(Buffer.from(password, "utf16le"))), + Buffer.from(md4(Buffer.from(password, "utf16le"))), Buffer.from(username.toUpperCase() + domain, "utf16le") ) diff --git a/packages/sql/mssql/test/Md4.test.ts b/packages/sql/mssql/test/Md4.test.ts new file mode 100644 index 00000000000..1771caa8550 --- /dev/null +++ b/packages/sql/mssql/test/Md4.test.ts @@ -0,0 +1,55 @@ +import { md4 } from "#tds/md4" +import { describe, expect, it } from "@effect/vitest" +import { Buffer } from "node:buffer" + +describe("MD4 for NTLM", () => { + it("matches every RFC 1320 appendix A.5 test vector", () => { + for ( + const [input, expected] of [ + ["", "31d6cfe0d16ae931b73c59d7e0c089c0"], + ["a", "bde52cb31de33e46245e05fbdbd6fb24"], + ["abc", "a448017aaf21d8525fc10ae87aa6729d"], + ["message digest", "d9130a8164549fe818874806e1c7014b"], + ["abcdefghijklmnopqrstuvwxyz", "d79e1c308aa5bbcdeea8ed63df412da9"], + ["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", "043f8582f241db351ce627e153e7f0e4"], + ["1234567890".repeat(8), "e33b4ddc9c38f2199c3e7b164fcc0536"] + ] + ) { + expect(Buffer.from(md4(Buffer.from(input))).toString("hex")).toBe(expected) + } + }) + + it("matches js-md4 0.3.2 fixtures at padding and block boundaries", () => { + // Fixtures generated with js-md4.hex(Uint8Array.from({ length }, + // (_, i) => (i * 37 + 11) & 255)), before removing the dependency. + for ( + const [length, expected] of [ + [55, "6cca0c744e9bc4fa913169558377fbba"], + [56, "d922db7b12a3e5c5b2ba42888e683018"], + [57, "b0ab24b68ca2884a4ceab0263ff90623"], + [63, "3b957b68471313efde691f4a3b052567"], + [64, "9f0d5ce97f5342937c8971e6da28dd46"], + [65, "552a1758bfae36468d7bdde13f589620"], + [119, "da03f1576623c8df9ff9a2a34f178c64"], + [120, "ba9a90900afde8b28a962aae2bcab45e"], + [127, "e2c83376d692bfa76853538d2879793a"], + [128, "30d7ef884bca324048651888e3d5fc58"], + [129, "e2f18e525c38ac135599c664c74967d5"], + [4096, "9bbae73a16def4771c364b2db08d1bb6"] + ] as const + ) { + const input = Uint8Array.from({ length }, (_, i) => (i * 37 + 11) & 255) + const original = input.slice() + expect(Buffer.from(md4(input)).toString("hex")).toBe(expected) + expect(input).toEqual(original) + const storage = new Uint8Array(length + 7).fill(0xff) + storage.set(input, 3) + expect(Buffer.from(md4(storage.subarray(3, 3 + length))).toString("hex")).toBe(expected) + } + }) + + it("produces the NT password hash from UTF-16LE bytes", () => { + expect(Buffer.from(md4(Buffer.from("Password", "utf16le"))).toString("hex")) + .toBe("a4f49c406510bdcab6824ee7c30fd852") + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a316bc66def..d504ce11e18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,9 +554,6 @@ importers: iconv-lite: specifier: ^0.7.0 version: 0.7.3 - js-md4: - specifier: ^0.3.2 - version: 0.3.2 devDependencies: '@testcontainers/mssqlserver': specifier: ^12.1.0