pgserve 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/protocol.js DELETED
@@ -1,389 +0,0 @@
1
- /**
2
- * PostgreSQL Wire Protocol Parser (Performance Optimized)
3
- *
4
- * Extracts database name from PostgreSQL startup message
5
- * https://www.postgresql.org/docs/current/protocol-message-formats.html
6
- *
7
- * Optimizations:
8
- * - Fast path for database extraction (skip full parsing if possible)
9
- * - Minimize string allocations
10
- * - Use Buffer.indexOf for faster null-byte search
11
- */
12
-
13
- const PROTOCOL_VERSION_3 = 196608;
14
- const SSL_REQUEST_CODE = 80877103; // PostgreSQL SSL negotiation request
15
- const GSSAPI_REQUEST_CODE = 80877104; // PostgreSQL GSSAPI encryption request
16
- const CANCEL_REQUEST_CODE = 80877102; // PostgreSQL cancel request
17
-
18
- /**
19
- * Parse PostgreSQL startup message to extract connection parameters
20
- * OPTIMIZED: Fast path for database extraction
21
- *
22
- * @param {Buffer} data - Raw startup message data
23
- * @param {boolean} [fastPath=true] - Use fast path (only extract database)
24
- * @returns {Object} Parsed parameters (user, database, application_name, etc.)
25
- */
26
- export function parseStartupMessage(data, fastPath = true) {
27
- const length = data.readInt32BE(0);
28
- const version = data.readInt32BE(4);
29
-
30
- // Verify protocol version (3.0 = 196608)
31
- if (version !== PROTOCOL_VERSION_3) {
32
- throw new Error(`Unsupported protocol version: ${version}`);
33
- }
34
-
35
- // Fast path: only extract database name (most common case)
36
- if (fastPath) {
37
- const dbName = extractDatabaseFast(data, 8, length);
38
- if (dbName) {
39
- return { database: dbName };
40
- }
41
- // Fallback to full parse if fast path failed
42
- }
43
-
44
- // Full parse (slower but complete)
45
- const params = {};
46
- let offset = 8;
47
-
48
- while (offset < length - 1) {
49
- // Find next null byte (key end)
50
- const keyEnd = data.indexOf(0, offset);
51
- if (keyEnd === -1 || keyEnd >= length) break;
52
-
53
- // Extract key (avoid toString for common keys)
54
- const key = data.toString('utf8', offset, keyEnd);
55
- offset = keyEnd + 1;
56
-
57
- // Find next null byte (value end)
58
- const valueEnd = data.indexOf(0, offset);
59
- if (valueEnd === -1 || valueEnd >= length) break;
60
-
61
- // Extract value
62
- const value = data.toString('utf8', offset, valueEnd);
63
- offset = valueEnd + 1;
64
-
65
- params[key] = value;
66
- }
67
-
68
- return params;
69
- }
70
-
71
- /**
72
- * Fast path: Extract database name without parsing all parameters
73
- * PERFORMANCE: ~3x faster than full parse
74
- *
75
- * @param {Buffer} data - Startup message buffer
76
- * @param {number} offset - Start offset (after header)
77
- * @param {number} length - Total message length
78
- * @returns {string|null} Database name or null
79
- */
80
- function extractDatabaseFast(data, offset, length) {
81
- // Search for "database\0" key
82
- while (offset < length - 1) {
83
- // Find next null byte
84
- const nullPos = data.indexOf(0, offset);
85
- if (nullPos === -1 || nullPos >= length) break;
86
-
87
- const keyLength = nullPos - offset;
88
-
89
- // Check if this is the "database" key (compare bytes directly)
90
- if (keyLength === 8 && data[offset] === 0x64 /* 'd' */) {
91
- // Quick byte comparison for "database"
92
- if (
93
- data[offset + 1] === 0x61 && // 'a'
94
- data[offset + 2] === 0x74 && // 't'
95
- data[offset + 3] === 0x61 && // 'a'
96
- data[offset + 4] === 0x62 && // 'b'
97
- data[offset + 5] === 0x61 && // 'a'
98
- data[offset + 6] === 0x73 && // 's'
99
- data[offset + 7] === 0x65 // 'e'
100
- ) {
101
- // Found "database" key, extract value
102
- offset = nullPos + 1;
103
- const valueEnd = data.indexOf(0, offset);
104
- if (valueEnd === -1 || valueEnd >= length) return null;
105
-
106
- return data.toString('utf8', offset, valueEnd);
107
- }
108
- }
109
-
110
- // Skip to next key-value pair
111
- offset = nullPos + 1;
112
- const valueEnd = data.indexOf(0, offset);
113
- if (valueEnd === -1) break;
114
- offset = valueEnd + 1;
115
- }
116
-
117
- return null;
118
- }
119
-
120
- /**
121
- * Extract database name from startup message
122
- *
123
- * @param {Buffer} data - Raw startup message data
124
- * @returns {string} Database name (defaults to 'postgres')
125
- */
126
- export function extractDatabaseName(data) {
127
- try {
128
- const params = parseStartupMessage(data);
129
- return params.database || 'postgres';
130
- } catch (error) {
131
- console.warn('Failed to parse startup message:', error.message);
132
- return 'postgres'; // Fallback to default
133
- }
134
- }
135
-
136
- /**
137
- * Extract `application_name` from a startup message buffer. Returns null when
138
- * absent or when the buffer is malformed (callers fall back to no-auth).
139
- *
140
- * @param {Buffer} data
141
- * @returns {string|null}
142
- */
143
- export function extractApplicationName(data) {
144
- try {
145
- const params = parseStartupMessage(data, /* fastPath */ false);
146
- return typeof params.application_name === 'string' ? params.application_name : null;
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- /**
153
- * Return a new startup-message buffer with the `database` parameter replaced
154
- * by `newDbName`. All other parameters (and their order) are preserved by
155
- * default; pass `dropParams: ['application_name', ...]` to strip noisy
156
- * fields the daemon would rather not forward to PG verbatim. The 4-byte
157
- * length prefix at the start of the buffer is recomputed.
158
- *
159
- * Group 6 uses this on TCP-authenticated connections so a peer that presents
160
- * a token for fingerprint X is forced into fingerprint X's database, even
161
- * if the libpq client requested a different one.
162
- *
163
- * @param {Buffer} data — original startup message
164
- * @param {string} newDbName
165
- * @param {{dropParams?: string[]}} [opts]
166
- * @returns {Buffer}
167
- */
168
- export function rewriteDatabaseName(data, newDbName, opts = {}) {
169
- if (!Buffer.isBuffer(data)) throw new Error('rewriteDatabaseName: buffer required');
170
- if (typeof newDbName !== 'string' || newDbName.length === 0) {
171
- throw new Error('rewriteDatabaseName: non-empty newDbName required');
172
- }
173
- const length = data.readInt32BE(0);
174
- const version = data.readInt32BE(4);
175
- const drop = new Set(opts.dropParams || []);
176
-
177
- // Walk parameters; build a list of (key, value) pairs replacing 'database'.
178
- const pairs = [];
179
- let offset = 8;
180
- let sawDatabase = false;
181
- while (offset < length - 1) {
182
- const keyEnd = data.indexOf(0, offset);
183
- if (keyEnd === -1 || keyEnd >= length) break;
184
- const key = data.toString('utf8', offset, keyEnd);
185
- offset = keyEnd + 1;
186
- const valueEnd = data.indexOf(0, offset);
187
- if (valueEnd === -1 || valueEnd >= length) break;
188
- const value = data.toString('utf8', offset, valueEnd);
189
- offset = valueEnd + 1;
190
- if (drop.has(key)) continue;
191
- if (key === 'database') {
192
- pairs.push(['database', newDbName]);
193
- sawDatabase = true;
194
- } else {
195
- pairs.push([key, value]);
196
- }
197
- }
198
- if (!sawDatabase) pairs.push(['database', newDbName]);
199
-
200
- // Compute new buffer size: 4 (length) + 4 (version) + sum(key+1 + value+1) + 1 (terminator).
201
- let bodyLen = 0;
202
- for (const [k, v] of pairs) {
203
- bodyLen += Buffer.byteLength(k, 'utf8') + 1 + Buffer.byteLength(v, 'utf8') + 1;
204
- }
205
- const total = 4 + 4 + bodyLen + 1;
206
- const out = Buffer.alloc(total);
207
- out.writeInt32BE(total, 0);
208
- out.writeInt32BE(version, 4);
209
- let cur = 8;
210
- for (const [k, v] of pairs) {
211
- cur += out.write(k, cur, 'utf8');
212
- out[cur++] = 0;
213
- cur += out.write(v, cur, 'utf8');
214
- out[cur++] = 0;
215
- }
216
- out[cur++] = 0; // final terminator
217
- return out;
218
- }
219
-
220
- /**
221
- * Build a PostgreSQL ErrorResponse (`'E'`) frame.
222
- *
223
- * Used by the daemon to reject cross-fingerprint connection attempts
224
- * with SQLSTATE `28P01 invalid_authorization_specification` before the
225
- * peer's startup message ever reaches the underlying PG instance.
226
- *
227
- * Frame layout (PG protocol v3):
228
- * 'E' (1 byte) | length (4 bytes, includes itself) | <fields...> | '\0'
229
- *
230
- * Each field: type-byte | utf8 string | '\0'
231
- * Required fields per PG docs: 'S' (Severity), 'C' (SQLSTATE), 'M' (Message).
232
- * 'V' (localized severity, server >= 9.6) is included for parity with the
233
- * frames real Postgres emits — psql / pg drivers parse both transparently.
234
- *
235
- * @param {{severity?: string, sqlstate: string, message: string}} args
236
- * @returns {Buffer}
237
- */
238
- export function buildErrorResponse({ severity = 'FATAL', sqlstate, message }) {
239
- if (typeof sqlstate !== 'string' || sqlstate.length !== 5) {
240
- throw new TypeError('buildErrorResponse: sqlstate must be a 5-character string');
241
- }
242
- if (typeof message !== 'string' || message.length === 0) {
243
- throw new TypeError('buildErrorResponse: message must be a non-empty string');
244
- }
245
- const field = (typeChar, value) => {
246
- const valBytes = Buffer.byteLength(value, 'utf8');
247
- const buf = Buffer.alloc(1 + valBytes + 1);
248
- buf.writeUInt8(typeChar.charCodeAt(0), 0);
249
- buf.write(value, 1, 'utf8');
250
- buf.writeUInt8(0, 1 + valBytes);
251
- return buf;
252
- };
253
- const body = Buffer.concat([
254
- field('S', severity),
255
- field('V', severity),
256
- field('C', sqlstate),
257
- field('M', message),
258
- Buffer.from([0]),
259
- ]);
260
- const frameLength = 4 + body.length;
261
- const header = Buffer.alloc(5);
262
- header.writeUInt8(0x45, 0); // 'E'
263
- header.writeUInt32BE(frameLength, 1);
264
- return Buffer.concat([header, body]);
265
- }
266
-
267
- // Pre-allocated buffer pool for startup message parsing (avoids allocation per connection)
268
- const STARTUP_BUFFER_SIZE = 8192; // Max startup message is typically < 1KB
269
- const bufferPool = [];
270
- const MAX_POOL_SIZE = 100;
271
-
272
- function acquireBuffer() {
273
- return bufferPool.pop() || Buffer.allocUnsafe(STARTUP_BUFFER_SIZE);
274
- }
275
-
276
- function releaseBuffer(buf) {
277
- if (bufferPool.length < MAX_POOL_SIZE) {
278
- bufferPool.push(buf);
279
- }
280
- }
281
-
282
- /**
283
- * Read startup message from socket and buffer it
284
- * OPTIMIZED: Uses pre-allocated buffer pool to avoid allocation per connection
285
- *
286
- * @param {net.Socket} socket - TCP socket
287
- * @returns {Promise<{message: Buffer, allData: Buffer}>} Startup message and all buffered data
288
- */
289
- export async function readStartupMessage(socket) {
290
- return new Promise((resolve, reject) => {
291
- const buffer = acquireBuffer();
292
- let offset = 0;
293
- let expectedLength = null;
294
- let resolved = false;
295
-
296
- const onData = (chunk) => {
297
- if (resolved) return;
298
-
299
- // Copy chunk into pre-allocated buffer (avoids Buffer.concat allocation)
300
- const copyLen = Math.min(chunk.length, STARTUP_BUFFER_SIZE - offset);
301
- chunk.copy(buffer, offset, 0, copyLen);
302
- offset += copyLen;
303
-
304
- // Read expected length from first 4 bytes
305
- if (expectedLength === null && offset >= 4) {
306
- expectedLength = buffer.readInt32BE(0);
307
- }
308
-
309
- // Check if we have full message
310
- if (expectedLength !== null && offset >= expectedLength) {
311
- resolved = true;
312
- socket.removeListener('data', onData);
313
- socket.removeListener('error', onError);
314
-
315
- // Create result buffers (need to copy since we're reusing pool buffer)
316
- const message = Buffer.from(buffer.subarray(0, expectedLength));
317
- const allData = Buffer.from(buffer.subarray(0, offset));
318
- releaseBuffer(buffer);
319
- resolve({ message, allData });
320
- }
321
- };
322
-
323
- const onError = (error) => {
324
- if (resolved) return;
325
- resolved = true;
326
- socket.removeListener('data', onData);
327
- socket.removeListener('error', onError);
328
- releaseBuffer(buffer);
329
- reject(error);
330
- };
331
-
332
- socket.on('data', onData);
333
- socket.on('error', onError);
334
-
335
- // Resume socket AFTER listeners are set up (prevents race condition)
336
- socket.resume();
337
-
338
- // Timeout after 2 seconds (reduced from 5s for faster probe connection handling)
339
- setTimeout(() => {
340
- if (resolved) return;
341
- resolved = true;
342
- socket.removeListener('data', onData);
343
- socket.removeListener('error', onError);
344
- releaseBuffer(buffer);
345
- reject(new Error('Timeout reading startup message'));
346
- }, 2000);
347
- });
348
- }
349
-
350
- /**
351
- * Extract database name from socket connection (with SSL negotiation support)
352
- *
353
- * @param {net.Socket} socket - TCP socket
354
- * @returns {Promise<{dbName: string, buffered: Buffer}>} Database name and buffered data
355
- */
356
- // Unused but kept for potential future use
357
- async function _extractDatabaseNameFromSocket(socket) {
358
- let { message, allData } = await readStartupMessage(socket);
359
-
360
- // Check if this is a protocol negotiation request (SSL, GSSAPI, Cancel)
361
- if (message.length >= 8) {
362
- const version = message.readInt32BE(4);
363
-
364
- if (version === SSL_REQUEST_CODE) {
365
- // Respond with 'N' (no SSL support)
366
- socket.write(Buffer.from('N'));
367
-
368
- // Read the actual startup message
369
- const result = await readStartupMessage(socket);
370
- message = result.message;
371
- allData = result.allData;
372
- } else if (version === GSSAPI_REQUEST_CODE) {
373
- // Respond with 'N' (no GSSAPI support)
374
- socket.write(Buffer.from('N'));
375
-
376
- // Read the actual startup message
377
- const result = await readStartupMessage(socket);
378
- message = result.message;
379
- allData = result.allData;
380
- } else if (version === CANCEL_REQUEST_CODE) {
381
- // Cancel request - query cancellation not implemented
382
- // Just close gracefully (cancel requests don't expect a response)
383
- throw new Error('Cancel request received (not supported)');
384
- }
385
- }
386
-
387
- const dbName = extractDatabaseName(message);
388
- return { dbName, buffered: allData };
389
- }