diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3d2b1e..5dfb61d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,13 @@ jobs: echo "----- Installing PostgreSQL ${{ matrix.postgres }} -----" sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - - sudo apt-get update + # The runner image ships a Google Chrome apt source we never use here; + # when its upstream Release/Packages files briefly disagree (a known, + # intermittent issue on their end), `apt-get update` fails outright and + # takes the postgresql-org repo update down with it. Drop it first so a + # repo we don't need can't block one we do. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update sudo apt-get -y install postgresql-${{ matrix.postgres }} postgresql-client-${{ matrix.postgres }} echo "----- Configuring -----" sudo cp ./test/_support/pg_hba.conf /etc/postgresql/${{ matrix.postgres }}/main/pg_hba.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e95ec..f8b6490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ ## Changelog -### [v3.0.4](https://github.com/panates/postgrejs/compare/v3.0.3...v3.0.4) - +### [v3.1.0](https://github.com/panates/postgrejs/compare/v3.0.4...v3.1.0) - + +#### πŸš€ New Features + +- feat: implement the legacy Function Call sub-protocol ('F'/'V') @Eray Hanoğlu +- feat: support protocol 3.2's longer cancellation key (PostgreSQL 18+) @Eray Hanoğlu + +#### πŸͺ² Fixes + +- fix: correctly parse NegotiateProtocolVersion and expose it on Connection @Eray Hanoğlu + +### [v3.0.4](https://github.com/panates/postgrejs/compare/v3.0.3...v3.0.4) - 9 September 2026 ### [v3.0.3](https://github.com/panates/postgrejs/compare/v3.0.2...v3.0.3) - 8 September 2026 diff --git a/README.md b/README.md index 0912526..eb4ae4c 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,18 @@ usage. - **Resource Management:** Auto disposal of resources with the "using" syntax ([TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)), ensuring efficient resource cleanup. +- **Long Cancel Key:** Opt in to protocol 3.2 (PostgreSQL 18+) with `longCancelKey`, so a `cancel()` in progress can't + be forged by an attacker guessing a short secret key. +- **Legacy Function Call Protocol:** `callFunction()` calls a function by OID directly over the wire, bypassing SQL + entirely - kept for protocol completeness even though `SELECT func(...)` covers the same ground. +- **Graceful Protocol Renegotiation:** A server that doesn't recognize a requested protocol version or startup option + reports back instead of erroring out, surfaced on `Connection.protocolNegotiation`. ## Feature Comparison How PostgreJS compares to [`pg`](https://github.com/brianc/node-postgres) (node-postgres) and [`postgres`](https://github.com/porsager/postgres) (postgres.js). Every row was checked against the libraries' own -source rather than their documentation β€” versions compared: **PostgreJS 2.23.1, pg 8.23.0, postgres.js 3.4.9**. βœ… built +source rather than their documentation β€” versions compared: **PostgreJS 3.1.0, pg 8.23.0, postgres.js 3.4.9**. βœ… built in Β· 🟑 partial or needs a separate package Β· ❌ not supported. | Feature | PostgreJS | pg | postgres.js | @@ -115,11 +121,15 @@ in Β· 🟑 partial or needs a separate package Β· ❌ not supported. | Module system | ESM | ESM/CJS | ESM/CJS | | Language | TS | JS 2 | JS 3 | | ***Wire protocol*** | | | | +| Protocol version | 3.2 | 3.0 | 3.0 | | Simple Query protocol | βœ… | βœ… | βœ… | | Extended Query protocol | βœ… | βœ… | βœ… | | Text wire format | βœ… | βœ… | βœ… | | Binary wire format | βœ… | 🟑 4 | ❌ 5 | | Per-column format selection | βœ… | ❌ | ❌ | +| Long cancel key (opt-in) | βœ… | ❌ | ❌ | +| Legacy Function Call protocol | βœ… | ❌ | ❌ | +| Graceful protocol renegotiation | βœ… | ❌ | ❌ | | ***High-level API*** | | | | | Object and array row modes | βœ… | βœ… | βœ… | | Dynamic SQL helpers | βœ… `sql` tag | ❌ | βœ… | diff --git a/package-lock.json b/package-lock.json index b93006d..4800415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "postgrejs", - "version": "3.0.4", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postgrejs", - "version": "3.0.4", + "version": "3.1.0", "license": "BSD-3-Clause", "dependencies": { "@jsopen/objects": "^2.2.3", diff --git a/package.json b/package.json index e542452..6fc839e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgrejs", "description": "Professional PostgreSQL client NodeJS", - "version": "3.0.4", + "version": "3.1.0", "author": "Panates", "license": "BSD-3-Clause", "private": true, diff --git a/src/connection/connection.ts b/src/connection/connection.ts index 62917aa..54f6b18 100644 --- a/src/connection/connection.ts +++ b/src/connection/connection.ts @@ -1,6 +1,8 @@ import { ConnectionState, DataTypeOIDs } from '../constants.js'; import { GlobalTypeMap } from '../data-type-map.js'; import type { ConnectionConfiguration } from '../interfaces/database-connection-params.js'; +import type { FunctionCallOptions } from '../interfaces/function-call-options.js'; +import type { FunctionCallResult } from '../interfaces/function-call-result.js'; import type { QueryOptions } from '../interfaces/query-options.js'; import type { QueryResult } from '../interfaces/query-result.js'; import type { ScriptExecuteOptions } from '../interfaces/script-execute-options.js'; @@ -82,10 +84,23 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable { return this._intlCon.sessionParameters; } + /** + * The server's NegotiateProtocolVersion reply, if it sent one during + * connect() - undefined means the server fully recognized everything + * this client's startup packet asked for (protocol minor version, any + * `_pq_.*` options). Present only when the server is older/stricter + * than what was requested, or didn't recognize one of the options - + * check it after connect() if a feature gated behind such an option + * doesn't seem to have taken effect. + */ + get protocolNegotiation(): Maybe { + return this._intlCon.protocolNegotiation; + } + /** * Returns the secret key of the current session */ - get secretKey(): Maybe { + get secretKey(): Maybe { return this._intlCon.secretKey; } @@ -193,6 +208,36 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable { ); } + /** + * The legacy Function Call sub-protocol - calls a function by OID + * directly, bypassing SQL entirely. Superseded by `SELECT func(...)` + * over the Simple/Extended Query protocols (what `execute()`/`query()` + * use, and what every current PostgreSQL client uses exclusively) - + * kept only for wire-protocol completeness. Arguments and the result + * travel as raw wire-format bytes, not JS values: the caller is + * responsible for encoding/decoding them (see a `DataType`'s own + * `encodeBinary`/`decodeBinary` for the format a given OID expects). + * + * @param functionId - OID of the function to call + * @param args - Each argument's own already-encoded wire bytes, or + * `null` for SQL NULL + */ + callFunction( + functionId: OID, + args: Maybe[], + options?: FunctionCallOptions & { signal?: AbortSignal }, + ): Promise { + return withAbortSignal( + options?.signal, + () => this._intlCon.cancel(), + () => + this._captureErrorStack( + this._intlCon.callFunction(functionId, args, options), + this.callFunction, + ), + ); + } + async query( sql: string | QueryRequest, options?: QueryOptions, diff --git a/src/connection/intl-connection.ts b/src/connection/intl-connection.ts index c6243b4..1770684 100644 --- a/src/connection/intl-connection.ts +++ b/src/connection/intl-connection.ts @@ -5,6 +5,8 @@ import { GlobalTypeMap } from '../data-type-map.js'; import type { CommandResult } from '../interfaces/command-result.js'; import type { ConnectionConfiguration } from '../interfaces/database-connection-params.js'; import type { FieldInfo } from '../interfaces/field-info.js'; +import type { FunctionCallOptions } from '../interfaces/function-call-options.js'; +import type { FunctionCallResult } from '../interfaces/function-call-result.js'; import type { QueryOptions } from '../interfaces/query-options.js'; import type { QueryResult } from '../interfaces/query-result.js'; import type { ScriptExecuteOptions } from '../interfaces/script-execute-options.js'; @@ -91,10 +93,15 @@ export class IntlConnection extends SafeEventEmitter { return this.socket.processID; } - get secretKey(): Maybe { + get secretKey(): Maybe { return this.socket.secretKey; } + /** See `PgSocket.protocolNegotiation`. */ + get protocolNegotiation(): Maybe { + return this.socket.protocolNegotiation; + } + get sessionParameters(): Record { return this.socket.sessionParameters; } @@ -195,6 +202,60 @@ export class IntlConnection extends SafeEventEmitter { } } + /** + * The legacy Function Call sub-protocol: calls a function by OID + * directly, bypassing SQL entirely. PostgreSQL itself calls this + * superseded by `SELECT func(...)` over the Simple/Extended Query + * protocols (what this driver uses everywhere else, and what every + * other current client uses exclusively) - kept only for wire-protocol + * completeness. Arguments and the result travel as raw wire-format + * bytes rather than through this driver's usual typed encode/decode + * pipeline - the caller is responsible for both (see a `DataType`'s own + * `encodeBinary`/`decodeBinary` for the format a given OID expects). + */ + async callFunction( + functionId: OID, + args: Maybe[], + options: FunctionCallOptions = {}, + ): Promise { + this.assertConnected(); + this.ref(); + try { + let result: FunctionCallResult | undefined; + let error: Error | undefined; + return await this.socket.sendFunctionCallMessage( + { + functionId, + args, + argFormats: options.argFormats, + resultFormat: options.resultFormat, + }, + (code, msg, done) => { + switch (code) { + case Protocol.BackendMessageCode.ErrorResponse: + error = msg; + break; + case Protocol.BackendMessageCode.FunctionCallResponse: + result = msg as FunctionCallResult; + break; + case Protocol.BackendMessageCode.ReadyForQuery: + this.transactionStatus = msg.status; + if (error) { + done(error); + break; + } + done(undefined, result); + break; + default: + break; + } + }, + ); + } finally { + this.unref(); + } + } + /** * Starts a transaction, or - if one is already running - marks a nested * level of it. Each call increments `_transactionDepth`; only the diff --git a/src/index.ts b/src/index.ts index 38eedd5..85d6b36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ export * from './interfaces/data-mapping-options.js'; export * from './interfaces/data-type.js'; export * from './interfaces/database-connection-params.js'; export * from './interfaces/field-info.js'; +export * from './interfaces/function-call-options.js'; +export * from './interfaces/function-call-result.js'; export * from './interfaces/query-options.js'; export * from './interfaces/query-result.js'; export * from './interfaces/script-execute-options.js'; diff --git a/src/interfaces/database-connection-params.ts b/src/interfaces/database-connection-params.ts index 7e6a8bd..d292bee 100644 --- a/src/interfaces/database-connection-params.ts +++ b/src/interfaces/database-connection-params.ts @@ -59,6 +59,18 @@ export interface DatabaseConnectionParams { * usual case with `sslmode=require` or a self-signed certificate. */ channelBinding?: 'prefer' | 'require' | 'disable'; + /** + * Requests protocol 3.2 (PostgreSQL 18+) instead of 3.0, so a + * `cancel()` in progress on this connection can't be forged by an + * attacker guessing a 4-byte secret key - 3.2 lets the server hand out + * one up to 256 bytes instead. Off by default: an older server simply + * reports back that it doesn't support 3.2 (see + * `Connection.protocolNegotiation`) and the session proceeds at 3.0 + * exactly as if this had never been set, so turning it on is never a + * connection-breaking choice - just not something every server rewards + * yet. + */ + longCancelKey?: boolean; requireSSL?: boolean; ssl?: TlsConnectionOptions; timezone?: string; diff --git a/src/interfaces/function-call-options.ts b/src/interfaces/function-call-options.ts new file mode 100644 index 0000000..ec2f5d1 --- /dev/null +++ b/src/interfaces/function-call-options.ts @@ -0,0 +1,15 @@ +import type { DataFormat } from '../constants.js'; + +export interface FunctionCallOptions { + /** + * Format of each argument, applied positionally - omit for text (the + * default) applied to every argument, a single entry to apply it to all + * of them, or one entry per argument. + */ + argFormats?: DataFormat[]; + /** + * Format the function's return value comes back in. + * @default DataFormat.text + */ + resultFormat?: DataFormat; +} diff --git a/src/interfaces/function-call-result.ts b/src/interfaces/function-call-result.ts new file mode 100644 index 0000000..c2a8865 --- /dev/null +++ b/src/interfaces/function-call-result.ts @@ -0,0 +1,7 @@ +export interface FunctionCallResult { + /** + * The function's return value, in the wire format requested via + * `FunctionCallOptions.resultFormat` - `null` if it returned SQL NULL. + */ + result: Buffer | null; +} diff --git a/src/protocol/backend.ts b/src/protocol/backend.ts index 79d4ea4..b4460d5 100644 --- a/src/protocol/backend.ts +++ b/src/protocol/backend.ts @@ -197,10 +197,13 @@ function parseAuthentication( } function parseBackendKeyData(io: BufferReader): Protocol.BackendKeyDataMessage { - return { - processID: io.readUInt32BE(), - secretKey: io.readUInt32BE(), - } as Protocol.BackendKeyDataMessage; + const processID = io.readUInt32BE(); + // No length prefix of its own: this BufferReader is scoped to exactly + // this message's body (see Backend.parse()), so the secret key is + // simply whatever bytes remain - 4 of them before protocol 3.2, up to + // 256 with it (see VERSION_MINOR_LONG_CANCEL_KEY). + const secretKey = io.readBuffer(); + return { processID, secretKey }; } function parseCommandComplete( @@ -279,22 +282,23 @@ function parseNotificationResponse( function parseFunctionCallResponse( io: BufferReader, - code: Protocol.BackendMessageCode, - len: number, ): Protocol.FunctionCallResponseMessage { - return { - result: io.readBuffer(len - 4), - } as Protocol.FunctionCallResponseMessage; + // A length prefix ahead of the value itself (-1 for SQL NULL, with no + // bytes following) - not deducible from the outer message length alone, + // unlike most fixed-shape messages this codebase parses. + const len = io.readInt32BE(); + return { result: len < 0 ? null : io.readBuffer(len) }; } function parseNegotiateProtocolVersion( io: BufferReader, ): Protocol.NegotiateProtocolVersionMessage { - return { - supportedVersionMinor: io.readUInt32BE(), - numberOfNotSupportedVersions: io.readUInt32BE(), - option: io.readCString('utf8'), - } as Protocol.NegotiateProtocolVersionMessage; + const supportedVersionMinor = io.readUInt32BE(); + const count = io.readUInt32BE(); + const unrecognizedOptions: string[] = new Array(count); + let i: number; + for (i = 0; i < count; i++) unrecognizedOptions[i] = io.readCString('utf8'); + return { supportedVersionMinor, unrecognizedOptions }; } function parseParameterDescription( diff --git a/src/protocol/frontend.ts b/src/protocol/frontend.ts index 9b0f050..d54485e 100644 --- a/src/protocol/frontend.ts +++ b/src/protocol/frontend.ts @@ -79,6 +79,30 @@ export namespace Frontend { type: 'P' | 'S'; name?: string; } + + /** + * The legacy Function Call sub-protocol - calls a function by OID + * directly, bypassing SQL entirely. Superseded by `SELECT func(...)` + * over the Simple/Extended Query protocols (which every current + * PostgreSQL client, this one included for everything else, uses + * exclusively) - kept only for wire-protocol completeness. Arguments + * and the result travel as raw wire-format bytes, not JS values: the + * caller is responsible for encoding/decoding them (see a `DataType`'s + * own `encodeBinary`/`decodeBinary` for the format a given OID expects). + */ + export interface FunctionCallMessageArgs { + functionId: OID; + /** + * Format of each argument in `args`, applied positionally - empty + * means text for all of them, a single entry applies to all, or it + * must have exactly one entry per argument. + */ + argFormats?: Protocol.DataFormat[]; + /** `null` for an SQL NULL argument. */ + args: Maybe[]; + /** Format the function's return value comes back in. @default text */ + resultFormat?: Protocol.DataFormat; + } } export class Frontend { @@ -97,12 +121,15 @@ export class Frontend { .flush(); } - getStartupMessage(args: Frontend.StartupMessageArgs): Buffer { + getStartupMessage( + args: Frontend.StartupMessageArgs, + minorVersion: number = Protocol.VERSION_MINOR, + ): Buffer { const io = this._io .start() .writeInt32BE(0) // Preserve length .writeInt16BE(Protocol.VERSION_MAJOR) - .writeInt16BE(Protocol.VERSION_MINOR); + .writeInt16BE(minorVersion); const entries = Object.entries(args); const l = entries.length; let k: string; @@ -124,14 +151,14 @@ export class Frontend { * is not reading its own socket, which is the whole reason this cannot go * down the normal one. */ - getCancelRequestMessage(processID: number, secretKey: number): Buffer { + getCancelRequestMessage(processID: number, secretKey: Buffer): Buffer { return this._io .start() - .writeUInt32BE(16) // Length of message contents in bytes, including self. + .writeUInt32BE(12 + secretKey.length) // Length of message contents in bytes, including self. .writeUInt16BE(1234) .writeUInt16BE(5678) .writeUInt32BE(processID) - .writeUInt32BE(secretKey) + .writeBuffer(secretKey) .flush(); } @@ -356,6 +383,30 @@ export class Frontend { return setLengthAndFlush(io, 1); } + getFunctionCallMessage(args: Frontend.FunctionCallMessageArgs): Buffer { + const { functionId, args: values, argFormats, resultFormat } = args; + const formats = argFormats || []; + const fl = formats.length; + const l = values.length; + let i: number; + const io = this._io + .start() + .writeInt8(Protocol.FrontendMessageCode.FunctionCall) + .writeInt32BE(0) // Preserve header + .writeInt32BE(functionId) + .writeInt16BE(fl); + for (i = 0; i < fl; i++) io.writeInt16BE(formats[i]); + io.writeInt16BE(l); + let v: Maybe; + for (i = 0; i < l; i++) { + v = values[i]; + if (v == null) io.writeInt32BE(-1); + else io.writeInt32BE(v.length).writeBuffer(v); + } + io.writeInt16BE(resultFormat ?? DataFormat.text); + return setLengthAndFlush(io, 1); + } + /** * Returns the CopyData header and the caller's payload as two buffers * rather than one, so a bulk import never copies the caller's bytes. diff --git a/src/protocol/pg-socket.ts b/src/protocol/pg-socket.ts index 8b28c18..ff79007 100644 --- a/src/protocol/pg-socket.ts +++ b/src/protocol/pg-socket.ts @@ -42,7 +42,8 @@ export class PgSocket extends SafeEventEmitter { private _sessionParameters: Record = {}; private _saslSession?: SASL.Session; private _processID?: number; - private _secretKey?: number; + private _secretKey?: Buffer; + private _protocolNegotiation?: Protocol.NegotiateProtocolVersionMessage; private _captureQueue = new DoublyLinked(); private _pendingWrites: { data: Buffer; cb?: Callback }[] = []; private _flushScheduled = false; @@ -67,10 +68,21 @@ export class PgSocket extends SafeEventEmitter { return this._processID; } - get secretKey(): Maybe { + get secretKey(): Maybe { return this._secretKey; } + /** + * The server's NegotiateProtocolVersion reply, if it sent one - + * undefined means everything this client asked for in its startup + * packet (protocol minor version, any `_pq_.*` options) was fully + * recognized. Present only when the server is older/stricter than what + * was requested, or didn't recognize one of the startup options. + */ + get protocolNegotiation(): Maybe { + return this._protocolNegotiation; + } + get sessionParameters(): Record { return this._sessionParameters; } @@ -277,6 +289,19 @@ export class PgSocket extends SafeEventEmitter { ); } + /** The legacy Function Call sub-protocol - see `Frontend.FunctionCallMessageArgs`. */ + sendFunctionCallMessage( + args: Frontend.FunctionCallMessageArgs, + cb: CaptureCallback, + ): Promise { + return this._sendAndCapture( + this._frontend.getFunctionCallMessage(args), + cb, + 'sendFunctionCallMessage', + args, + ); + } + /** * Sends Parse+Bind+Describe+Execute+Sync as a single write with a single * FIFO capture entry, instead of 5 separate sendXMessage() round trips - @@ -555,14 +580,19 @@ export class PgSocket extends SafeEventEmitter { socket.on('error', (err: SocketError) => this._handleError(err)); socket.on('close', () => this._handleClose()); this._send( - this._frontend.getStartupMessage({ - user: this.options.user || 'postgres', - database: this.options.database || '', - application_name: this.options.applicationName || '', - ...(this.options.replication - ? { replication: this.options.replication } - : undefined), - }), + this._frontend.getStartupMessage( + { + user: this.options.user || 'postgres', + database: this.options.database || '', + application_name: this.options.applicationName || '', + ...(this.options.replication + ? { replication: this.options.replication } + : undefined), + }, + this.options.longCancelKey + ? Protocol.VERSION_MINOR_LONG_CANCEL_KEY + : undefined, + ), ); } @@ -702,6 +732,11 @@ export class PgSocket extends SafeEventEmitter { payload as Protocol.ParameterStatusMessage, ); break; + case Protocol.BackendMessageCode.NegotiateProtocolVersion: + this._handleNegotiateProtocolVersion( + payload as Protocol.NegotiateProtocolVersionMessage, + ); + break; case Protocol.BackendMessageCode.BackendKeyData: this._handleBackendKeyData( payload as Protocol.BackendKeyDataMessage, @@ -876,6 +911,25 @@ export class PgSocket extends SafeEventEmitter { this._sessionParameters[msg.name] = msg.value; } + protected _handleNegotiateProtocolVersion( + msg: Protocol.NegotiateProtocolVersionMessage, + ): void { + this._protocolNegotiation = msg; + /* c8 ignore start */ + if (this.listenerCount('debug')) { + this.emit('debug', { + location: 'PgSocket._handleNegotiateProtocolVersion', + message: + `server supports protocol 3.${msg.supportedVersionMinor}` + + (msg.unrecognizedOptions.length + ? `; did not recognize startup option(s): ${msg.unrecognizedOptions.join(', ')}` + : ''), + ...msg, + }); + } + /* c8 ignore stop */ + } + protected _handleBackendKeyData(msg: Protocol.BackendKeyDataMessage): void { this._processID = msg.processID; this._secretKey = msg.secretKey; diff --git a/src/protocol/protocol.ts b/src/protocol/protocol.ts index 55e6d23..160e504 100644 --- a/src/protocol/protocol.ts +++ b/src/protocol/protocol.ts @@ -1,6 +1,16 @@ export namespace Protocol { export const VERSION_MAJOR = 3; export const VERSION_MINOR = 0; + /** + * Minor version 3.2 (PostgreSQL 18+) differs from 3.0 only in that + * BackendKeyData's secret key - and so CancelRequest's - can be up to + * 256 bytes instead of always exactly 4. A server that doesn't support + * 3.2 replies with NegotiateProtocolVersion naming the highest minor + * version it does support (see `PgSocket.protocolNegotiation`) and the + * session simply proceeds at that version instead - requesting 3.2 is + * never a connection-breaking choice, only ever a possible no-op. + */ + export const VERSION_MINOR_LONG_CANCEL_KEY = 2; // https://www.postgresql.org/docs/9.3/protocol-message-formats.html export enum BackendMessageCode { @@ -116,7 +126,14 @@ export namespace Protocol { export interface BackendKeyDataMessage { processID: number; - secretKey: number; + /** + * Always 4 bytes before protocol 3.2, up to 256 with it - see + * `VERSION_MINOR_LONG_CANCEL_KEY`. Not a fixed-width int, unlike + * before: the field carries whatever bytes the server sent, in + * whatever order it sent them, to be relayed back to CancelRequest + * unchanged rather than interpreted as a number. + */ + secretKey: Buffer; } export interface CommandCompleteMessage { @@ -169,13 +186,19 @@ export namespace Protocol { } export interface FunctionCallResponseMessage { - result: Buffer; + /** `null` when the function returned SQL NULL. */ + result: Buffer | null; } export interface NegotiateProtocolVersionMessage { + /** Newest minor protocol version the server supports. */ supportedVersionMinor: number; - numberOfNotSupportedVersions: number; - option: string; + /** + * Startup packet options this client sent that the server didn't + * recognize - empty when the only mismatch is the protocol minor + * version itself. + */ + unrecognizedOptions: string[]; } export interface ParameterDescriptionMessage { diff --git a/test/A-common/backend.spec.ts b/test/A-common/backend.spec.ts index 1e529a7..2c7b203 100644 --- a/test/A-common/backend.spec.ts +++ b/test/A-common/backend.spec.ts @@ -109,32 +109,102 @@ describe('Backend (wire message parsing)', () => { }); }); + describe('BackendKeyData', () => { + it('should read a legacy 4-byte secret key (protocol 3.0)', () => { + const body = Buffer.alloc(8); + body.writeUInt32BE(1234, 0); // processID + body.write('\xd5\xd4\xb4\x4f', 4, 'binary'); // secretKey, 4 bytes + const { msg } = parseOne( + message(Protocol.BackendMessageCode.BackendKeyData, body), + ); + expect(msg.processID).toStrictEqual(1234); + expect(Buffer.isBuffer(msg.secretKey)).toBe(true); + expect(msg.secretKey).toStrictEqual( + Buffer.from([0xd5, 0xd4, 0xb4, 0x4f]), + ); + }); + + it('should read a longer secret key (protocol 3.2) as whatever bytes remain', () => { + const key = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); + const body = Buffer.alloc(4 + key.length); + body.writeUInt32BE(1, 0); + key.copy(body, 4); + const { msg } = parseOne( + message(Protocol.BackendMessageCode.BackendKeyData, body), + ); + expect(msg.secretKey).toStrictEqual(key); + }); + }); + describe('FunctionCallResponse', () => { - it('should read the result as a raw buffer', () => { - const result = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + it('should read the result value behind its own length prefix', () => { + const value = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const body = Buffer.alloc(4 + value.length); + body.writeInt32BE(value.length, 0); + value.copy(body, 4); const { code, msg } = parseOne( - message(Protocol.BackendMessageCode.FunctionCallResponse, result), + message(Protocol.BackendMessageCode.FunctionCallResponse, body), ); expect(code).toStrictEqual( Protocol.BackendMessageCode.FunctionCallResponse, ); - expect(msg.result).toStrictEqual(result); + expect(msg.result).toStrictEqual(value); + }); + + it('should read a -1 length prefix as a null result, with no bytes following', () => { + const body = Buffer.alloc(4); + body.writeInt32BE(-1, 0); + const { msg } = parseOne( + message(Protocol.BackendMessageCode.FunctionCallResponse, body), + ); + expect(msg.result).toBeNull(); }); }); describe('NegotiateProtocolVersion', () => { - it('should read the minor version, unsupported-option count and name', () => { + it('should read the minor version and one unrecognized option name', () => { const body = Buffer.alloc(12); body.writeUInt32BE(2, 0); // supportedVersionMinor - body.writeUInt32BE(1, 4); // numberOfNotSupportedVersions - body.write('foo\0', 8, 'utf8'); // option + body.writeUInt32BE(1, 4); // count + body.write('foo\0', 8, 'utf8'); + const { msg } = parseOne( + message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body), + ); + expect(msg).toStrictEqual({ + supportedVersionMinor: 2, + unrecognizedOptions: ['foo'], + }); + }); + + it('should read zero unrecognized options without consuming any string', () => { + // A mismatched protocol minor version alone, with every startup + // option otherwise recognized, reports a count of 0 and no strings + // follow - reading one anyway (the bug this guards against) would + // desync every message parsed after this one. + const body = Buffer.alloc(8); + body.writeUInt32BE(0, 0); // supportedVersionMinor + body.writeUInt32BE(0, 4); // count + const { msg } = parseOne( + message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body), + ); + expect(msg).toStrictEqual({ + supportedVersionMinor: 0, + unrecognizedOptions: [], + }); + }); + + it('should read multiple unrecognized option names in order', () => { + const body = Buffer.alloc(8 + 4 + 4); + body.writeUInt32BE(2, 0); // supportedVersionMinor + body.writeUInt32BE(2, 4); // count + body.write('foo\0', 8, 'utf8'); + body.write('bar\0', 12, 'utf8'); const { msg } = parseOne( message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body), ); expect(msg).toStrictEqual({ supportedVersionMinor: 2, - numberOfNotSupportedVersions: 1, - option: 'foo', + unrecognizedOptions: ['foo', 'bar'], }); }); }); diff --git a/test/A-common/frontend.spec.ts b/test/A-common/frontend.spec.ts index 9095ba9..8134e5d 100644 --- a/test/A-common/frontend.spec.ts +++ b/test/A-common/frontend.spec.ts @@ -348,6 +348,41 @@ describe('Frontend', () => { }); }); + describe('getFunctionCallMessage()', () => { + it('should write function id, arg formats, each arg (or -1 for null), and the result format', () => { + const frontend = new Frontend({}); + const arg0 = Buffer.from([0x01, 0x02]); + const buf = frontend.getFunctionCallMessage({ + functionId: 1234, + argFormats: [DataFormat.binary], + args: [arg0, null], + resultFormat: DataFormat.binary, + }); + const io = reader(buf); + expect(io.readInt32BE()).toStrictEqual(1234); + expect(io.readInt16BE()).toStrictEqual(1); // argFormats.length + expect(io.readInt16BE()).toStrictEqual(DataFormat.binary); + expect(io.readInt16BE()).toStrictEqual(2); // args.length + expect(io.readInt32BE()).toStrictEqual(arg0.length); + expect(io.readBuffer(arg0.length)).toStrictEqual(arg0); + expect(io.readInt32BE()).toStrictEqual(-1); // null arg + expect(io.readInt16BE()).toStrictEqual(DataFormat.binary); // result format + }); + + it('should default to an empty argFormats and text result format', () => { + const frontend = new Frontend({}); + const buf = frontend.getFunctionCallMessage({ + functionId: 1, + args: [], + }); + const io = reader(buf); + expect(io.readInt32BE()).toStrictEqual(1); + expect(io.readInt16BE()).toStrictEqual(0); // argFormats.length + expect(io.readInt16BE()).toStrictEqual(0); // args.length + expect(io.readInt16BE()).toStrictEqual(DataFormat.text); + }); + }); + describe('getCopyFailMessage()', () => { it('should default a falsy message to the empty string', () => { const frontend = new Frontend({}); @@ -356,4 +391,43 @@ describe('Frontend', () => { expect(io.readCString()).toStrictEqual(''); }); }); + + describe('getStartupMessage()', () => { + // No leading message code byte at all, unlike every other message + // here - StartupMessage/CancelRequest/SSLRequest are identified by + // their own magic number instead, so there is no 5-byte header for + // reader() to skip. + it('should default to protocol 3.0 when no minor version is given', () => { + const frontend = new Frontend({}); + const buf = frontend.getStartupMessage({ user: 'u', database: 'd' }); + expect(buf.readInt16BE(4)).toStrictEqual(3); // major + expect(buf.readInt16BE(6)).toStrictEqual(0); // minor + }); + + it('should write the given minor version', () => { + const frontend = new Frontend({}); + const buf = frontend.getStartupMessage({ user: 'u', database: 'd' }, 2); + expect(buf.readInt16BE(6)).toStrictEqual(2); + }); + }); + + describe('getCancelRequestMessage()', () => { + it('should write a 4-byte legacy secret key unchanged', () => { + const frontend = new Frontend({}); + const secretKey = Buffer.from([0x01, 0x02, 0x03, 0x04]); + const buf = frontend.getCancelRequestMessage(99, secretKey); + expect(buf.readUInt32BE(0)).toStrictEqual(16); // length, incl. self + expect(buf.readUInt32BE(4)).toStrictEqual(80877102); // cancel code + expect(buf.readUInt32BE(8)).toStrictEqual(99); // processID + expect(buf.subarray(12)).toStrictEqual(secretKey); + }); + + it('should write a longer secret key with a correspondingly longer length', () => { + const frontend = new Frontend({}); + const secretKey = Buffer.alloc(32, 0xab); + const buf = frontend.getCancelRequestMessage(1, secretKey); + expect(buf.readUInt32BE(0)).toStrictEqual(12 + secretKey.length); + expect(buf.subarray(12)).toStrictEqual(secretKey); + }); + }); }); diff --git a/test/A-common/pg-socket.spec.ts b/test/A-common/pg-socket.spec.ts index 4d582e3..7e362cf 100644 --- a/test/A-common/pg-socket.spec.ts +++ b/test/A-common/pg-socket.spec.ts @@ -11,6 +11,20 @@ describe('PgSocket', () => { }); }); + describe('protocolNegotiation', () => { + it('should start out undefined before any NegotiateProtocolVersion arrives', () => { + const socket = new PgSocket({}); + expect(socket.protocolNegotiation).toBeUndefined(); + }); + + it('should store the message once _handleNegotiateProtocolVersion runs', () => { + const socket: any = new PgSocket({}); + const msg = { supportedVersionMinor: 0, unrecognizedOptions: ['foo'] }; + socket._handleNegotiateProtocolVersion(msg); + expect(socket.protocolNegotiation).toStrictEqual(msg); + }); + }); + describe('_handleAuthenticationMessage()', () => { it('should throw on an authentication method it does not support', () => { const socket: any = new PgSocket({}); diff --git a/test/B-connection/01-connection.spec.ts b/test/B-connection/01-connection.spec.ts index 61ad6a4..525ea7f 100644 --- a/test/B-connection/01-connection.spec.ts +++ b/test/B-connection/01-connection.spec.ts @@ -70,7 +70,10 @@ describe('Connection', () => { it('should get secret key', async () => { connection = new Connection(); await connection.connect(); - expect(connection.secretKey).toBeGreaterThan(0); + // 4 bytes by default (protocol 3.0) - see 17-long-cancel-key.spec.ts + // for the longer key protocol 3.2 (longCancelKey: true) hands back. + expect(Buffer.isBuffer(connection.secretKey)).toBe(true); + expect(connection.secretKey?.length).toStrictEqual(4); }); it('should set application_name', async () => { diff --git a/test/B-connection/16-function-call.spec.ts b/test/B-connection/16-function-call.spec.ts new file mode 100644 index 0000000..69a04d7 --- /dev/null +++ b/test/B-connection/16-function-call.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'expect'; +import { Connection, DataFormat } from 'postgrejs'; + +describe('callFunction() (legacy Function Call sub-protocol)', () => { + let connection: Connection; + + before(async () => { + connection = new Connection(); + await connection.connect(); + }); + + after(async () => { + await connection.close(); + }); + + async function findOid(proname: string, pronargs: number): Promise { + const result = await connection.query( + 'select oid from pg_proc where proname = $1 and pronargs = $2', + { params: [proname, pronargs] }, + ); + return Number((result.rows as any[])[0][0]); + } + + it('should call a built-in function by OID with text arguments/result', async () => { + // md5, not upper: the latter needs a collation, which the executor + // can only resolve from a parsed SQL expression - a raw FunctionCall + // argument has no such context, so it fails with "could not + // determine which collation to use". md5 doesn't care about + // collation at all, so it works the same through either protocol. + const oid = await findOid('md5', 1); + const result = await connection.callFunction(oid, [ + Buffer.from('hello', 'utf8'), + ]); + expect(result.result?.toString('utf8')).toStrictEqual( + '5d41402abc4b2a76b9719d911017c592', + ); + }); + + it('should call a built-in function with binary arguments/result', async () => { + const oid = await findOid('int4pl', 2); + const a = Buffer.alloc(4); + a.writeInt32BE(2); + const b = Buffer.alloc(4); + b.writeInt32BE(3); + const result = await connection.callFunction(oid, [a, b], { + argFormats: [DataFormat.binary], + resultFormat: DataFormat.binary, + }); + expect(result.result?.readInt32BE()).toStrictEqual(5); + }); + + it('should return a null result for a strict function called with a null argument', async () => { + const oid = await findOid('md5', 1); + const result = await connection.callFunction(oid, [null]); + expect(result.result).toBeNull(); + }); + + it('should reject with the server error for an unknown function OID', async () => { + await expect(connection.callFunction(0, [])).rejects.toThrow(); + }); +}); diff --git a/test/B-connection/17-long-cancel-key.spec.ts b/test/B-connection/17-long-cancel-key.spec.ts new file mode 100644 index 0000000..46bcc20 --- /dev/null +++ b/test/B-connection/17-long-cancel-key.spec.ts @@ -0,0 +1,53 @@ +import { expect } from 'expect'; +import { Connection } from 'postgrejs'; + +describe('longCancelKey (protocol 3.2)', () => { + let connection: Connection; + + afterEach(async () => { + if (connection) await connection.close(0); + }); + + it('should hand back a longer secret key than the 4-byte default', async function () { + connection = new Connection({ longCancelKey: true }); + await connection.connect(); + // Protocol 3.2 (and its longer cancel key) only exists on PostgreSQL + // 18+; older servers gracefully negotiate back down to 3.0 and hand + // back the legacy 4-byte key instead - by design, not a bug. + const serverVersion = parseInt( + connection.sessionParameters.server_version, + 10, + ); + if (serverVersion < 18) return this.skip(); + // PostgreSQL sends up to 32 bytes today; the wire format itself + // allows up to 256 - either way, strictly more than the legacy 4. + expect(connection.secretKey?.length).toBeGreaterThan(4); + // Our own test server (PostgreSQL 18) fully supports 3.2, so nothing + // here should have needed negotiating down. + expect(connection.protocolNegotiation).toBeUndefined(); + }); + + it('should still cancel a running query with the longer key', async () => { + connection = new Connection({ longCancelKey: true }); + await connection.connect(); + const ac = new AbortController(); + const started = Date.now(); + setTimeout(() => ac.abort(), 200); + let error: any; + try { + await connection.query('select pg_sleep(10)', { signal: ac.signal }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.name).toStrictEqual('AbortError'); + expect(Date.now() - started).toBeLessThan(5000); + expect(error.cause?.code).toStrictEqual('57014'); + }); + + it('should default to the legacy 4-byte key when not set', async () => { + connection = new Connection(); + await connection.connect(); + expect(connection.secretKey?.length).toStrictEqual(4); + }); +});