neonctl 2.22.2 → 2.23.1

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.
Files changed (113) hide show
  1. package/README.md +84 -0
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/connection_string.js +9 -1
  5. package/commands/functions.js +268 -0
  6. package/commands/index.js +4 -0
  7. package/commands/neon_auth.js +1013 -0
  8. package/commands/projects.js +9 -1
  9. package/commands/psql.js +6 -1
  10. package/functions_api.js +43 -0
  11. package/package.json +15 -5
  12. package/psql/cli.js +51 -0
  13. package/psql/command/cmd_cond.js +437 -0
  14. package/psql/command/cmd_connect.js +815 -0
  15. package/psql/command/cmd_copy.js +1025 -0
  16. package/psql/command/cmd_describe.js +1810 -0
  17. package/psql/command/cmd_format.js +909 -0
  18. package/psql/command/cmd_io.js +2187 -0
  19. package/psql/command/cmd_lo.js +385 -0
  20. package/psql/command/cmd_meta.js +970 -0
  21. package/psql/command/cmd_misc.js +187 -0
  22. package/psql/command/cmd_pipeline.js +1141 -0
  23. package/psql/command/cmd_restrict.js +171 -0
  24. package/psql/command/cmd_show.js +751 -0
  25. package/psql/command/dispatch.js +343 -0
  26. package/psql/command/inputQueue.js +42 -0
  27. package/psql/command/shared.js +71 -0
  28. package/psql/complete/filenames.js +139 -0
  29. package/psql/complete/index.js +104 -0
  30. package/psql/complete/matcher.js +314 -0
  31. package/psql/complete/psqlVars.js +247 -0
  32. package/psql/complete/queries.js +491 -0
  33. package/psql/complete/rules.js +2387 -0
  34. package/psql/core/common.js +1250 -0
  35. package/psql/core/help.js +576 -0
  36. package/psql/core/mainloop.js +1353 -0
  37. package/psql/core/prompt.js +437 -0
  38. package/psql/core/settings.js +684 -0
  39. package/psql/core/sqlHelp.js +1066 -0
  40. package/psql/core/startup.js +840 -0
  41. package/psql/core/syncVars.js +116 -0
  42. package/psql/core/variables.js +287 -0
  43. package/psql/describe/formatters.js +1277 -0
  44. package/psql/describe/processNamePattern.js +270 -0
  45. package/psql/describe/queries.js +2373 -0
  46. package/psql/describe/versionGate.js +43 -0
  47. package/psql/index.js +2005 -0
  48. package/psql/io/history.js +299 -0
  49. package/psql/io/input.js +120 -0
  50. package/psql/io/lineEditor/buffer.js +323 -0
  51. package/psql/io/lineEditor/complete.js +227 -0
  52. package/psql/io/lineEditor/filename.js +159 -0
  53. package/psql/io/lineEditor/index.js +891 -0
  54. package/psql/io/lineEditor/keymap.js +738 -0
  55. package/psql/io/lineEditor/vt100.js +363 -0
  56. package/psql/io/pgpass.js +202 -0
  57. package/psql/io/pgservice.js +194 -0
  58. package/psql/io/psqlrc.js +422 -0
  59. package/psql/print/aligned.js +1756 -0
  60. package/psql/print/asciidoc.js +248 -0
  61. package/psql/print/crosstab.js +460 -0
  62. package/psql/print/csv.js +92 -0
  63. package/psql/print/html.js +258 -0
  64. package/psql/print/json.js +96 -0
  65. package/psql/print/latex.js +396 -0
  66. package/psql/print/pager.js +265 -0
  67. package/psql/print/troff.js +258 -0
  68. package/psql/print/unaligned.js +118 -0
  69. package/psql/print/units.js +135 -0
  70. package/psql/scanner/slash.js +513 -0
  71. package/psql/scanner/sql.js +910 -0
  72. package/psql/scanner/stringutils.js +390 -0
  73. package/psql/types/backslash.js +1 -0
  74. package/psql/types/connection.js +1 -0
  75. package/psql/types/index.js +7 -0
  76. package/psql/types/printer.js +1 -0
  77. package/psql/types/repl.js +1 -0
  78. package/psql/types/scanner.js +24 -0
  79. package/psql/types/settings.js +1 -0
  80. package/psql/types/variables.js +1 -0
  81. package/psql/wire/connection.js +2844 -0
  82. package/psql/wire/copy.js +108 -0
  83. package/psql/wire/notify.js +59 -0
  84. package/psql/wire/pipeline.js +519 -0
  85. package/psql/wire/protocol.js +466 -0
  86. package/psql/wire/sasl.js +296 -0
  87. package/psql/wire/tls.js +596 -0
  88. package/test_utils/fixtures.js +1 -0
  89. package/utils/esbuild.js +147 -0
  90. package/utils/psql.js +107 -11
  91. package/utils/zip.js +4 -0
  92. package/writer.js +1 -1
  93. package/commands/auth.test.js +0 -211
  94. package/commands/branches.test.js +0 -460
  95. package/commands/checkout.test.js +0 -170
  96. package/commands/connection_string.test.js +0 -196
  97. package/commands/data_api.test.js +0 -169
  98. package/commands/databases.test.js +0 -39
  99. package/commands/help.test.js +0 -9
  100. package/commands/init.test.js +0 -56
  101. package/commands/ip_allow.test.js +0 -59
  102. package/commands/link.test.js +0 -381
  103. package/commands/operations.test.js +0 -7
  104. package/commands/orgs.test.js +0 -7
  105. package/commands/projects.test.js +0 -144
  106. package/commands/psql.test.js +0 -49
  107. package/commands/roles.test.js +0 -37
  108. package/commands/set_context.test.js +0 -159
  109. package/commands/vpc_endpoints.test.js +0 -69
  110. package/context.test.js +0 -119
  111. package/env.test.js +0 -55
  112. package/utils/formats.test.js +0 -32
  113. package/writer.test.js +0 -104
@@ -0,0 +1,596 @@
1
+ /**
2
+ * TLS negotiation for the psql wire layer (WP-02).
3
+ *
4
+ * Two responsibilities:
5
+ *
6
+ * 1. Drive the PG-flavoured SSL handshake. Postgres negotiates TLS
7
+ * *before* the protocol startup: client sends an `SSLRequest`
8
+ * (8-byte fixed message), server replies with a single byte —
9
+ * 'S' to accept, 'N' to refuse. On 'S' we wrap the existing socket
10
+ * with `tls.connect({ socket })`; on 'N' we either bail (require/
11
+ * verify-*) or fall through plaintext (prefer/allow/disable).
12
+ *
13
+ * 2. Extract `tls-server-end-point` channel-binding material from the
14
+ * negotiated TLS session for SCRAM-SHA-256-PLUS. Per RFC 5929 §4 the
15
+ * data is the hash of the peer certificate computed with the cert's
16
+ * own signature hash, unless that hash is MD5 or SHA-1 — in which
17
+ * case the binding uses SHA-256. libpq's policy (`fe-secure-openssl.c`
18
+ * `PgChannelBinding`) is "always SHA-256 of the DER cert", which is
19
+ * also what the PG server expects. We follow libpq.
20
+ *
21
+ * Notes:
22
+ * - We deliberately do NOT validate the server cert here; that's the
23
+ * caller's responsibility (pass `tlsOpts.rejectUnauthorized` etc.). The
24
+ * ssl-mode → tls-options mapping lives in `connection.ts`.
25
+ * - `verify-ca` and `verify-full` differ only in hostname checking, which
26
+ * Node's `tls.connect` performs automatically when `checkServerIdentity`
27
+ * is the default and `servername` is set. The connection layer wires
28
+ * `servername` to the configured host before calling us.
29
+ */
30
+ import * as tls from 'node:tls';
31
+ import { createHash, createPrivateKey } from 'node:crypto';
32
+ import { promises as fs, appendFileSync } from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import { SSLRequest } from './protocol.js';
35
+ /**
36
+ * Hash of the peer cert for `tls-server-end-point`. libpq always uses SHA-256
37
+ * of the DER-encoded certificate; we match that.
38
+ *
39
+ * Exposed for tests so we can stub the peer cert.
40
+ */
41
+ export function computeChannelBindingData(cert) {
42
+ // `cert.raw` is the DER-encoded certificate. Strict typing in @types/node
43
+ // marks it as `Buffer | undefined` on some versions, hence the guard.
44
+ const raw = cert.raw;
45
+ if (!raw || raw.length === 0) {
46
+ throw new Error('TLS channel binding: peer certificate has no DER bytes');
47
+ }
48
+ return createHash('sha256').update(raw).digest();
49
+ }
50
+ /**
51
+ * Send SSLRequest, read the 1-byte server response, and either upgrade the
52
+ * socket to TLS or stay plain (depending on `sslMode`).
53
+ *
54
+ * `tlsOpts` is passed through to `tls.connect` — the connection layer fills
55
+ * in `host`, `servername`, `ca`, `rejectUnauthorized`, etc. before calling.
56
+ *
57
+ * `fileOpts` carries libpq-style PEM file paths (`sslcert`, `sslkey`,
58
+ * `sslrootcert`, `sslcrl`). Each present path is read from disk before the
59
+ * TLS handshake and threaded into the corresponding tls.connect option
60
+ * (`cert` / `key` / `ca` / `crl`). Read failures bubble out as
61
+ * `could not read ssl<…>: <message>` so the caller sees the libpq diagnostic
62
+ * shape rather than a bare ENOENT.
63
+ *
64
+ * `fileOpts.sslkeylogfile`, when set, is pre-checked for writability here and
65
+ * wired to a `'keylog'` listener on the upgraded socket so TLS session keys
66
+ * are appended for offline decryption.
67
+ *
68
+ * `negotiation` selects how TLS is started (libpq `sslnegotiation`):
69
+ * - `'postgres'` (default): send `SSLRequest` and await the 'S'/'N' reply.
70
+ * - `'direct'`: skip `SSLRequest` and start the TLS handshake immediately
71
+ * on the raw socket (PG 17+). The caller must have set
72
+ * `tlsOpts.ALPNProtocols` to `['postgresql']`; there is no plaintext
73
+ * fallback, so a server that does not speak TLS surfaces the handshake
74
+ * failure rather than a quiet downgrade.
75
+ */
76
+ export async function negotiateTls(socket, sslMode, tlsOpts = {}, fileOpts = {}, negotiation = 'postgres') {
77
+ if (sslMode === 'disable') {
78
+ return { kind: 'plain', socket };
79
+ }
80
+ // Direct SSL (libpq `sslnegotiation=direct`, PG 17+): skip the `SSLRequest`
81
+ // probe and start the TLS handshake straight away. The parse layer has
82
+ // already rejected weak sslmodes, so this path is only reached with an
83
+ // encrypted mode — never falling back to plaintext.
84
+ if (negotiation === 'direct') {
85
+ const mergedOpts = await loadTlsFileOptions(tlsOpts, fileOpts, sslMode);
86
+ return upgradeToTls(socket, mergedOpts, fileOpts.sslkeylogfile, fileOpts.sslkey,
87
+ /* requireAlpn */ true);
88
+ }
89
+ const reply = await sendSslRequest(socket);
90
+ if (reply === 'S') {
91
+ const mergedOpts = await loadTlsFileOptions(tlsOpts, fileOpts, sslMode);
92
+ return upgradeToTls(socket, mergedOpts, fileOpts.sslkeylogfile, fileOpts.sslkey);
93
+ }
94
+ // reply === 'N': server refused TLS.
95
+ if (sslMode === 'require' ||
96
+ sslMode === 'verify-ca' ||
97
+ sslMode === 'verify-full') {
98
+ throw new Error(`SSL connection required (sslmode=${sslMode}) but server refused (replied 'N')`);
99
+ }
100
+ // 'allow' / 'prefer': fall back to plain text.
101
+ return { kind: 'plain', socket };
102
+ }
103
+ /**
104
+ * Read each non-empty file path in `fileOpts` and merge the bytes onto a
105
+ * shallow copy of `tlsOpts`. Each ENOENT / EACCES / permission error is
106
+ * surfaced as `could not read ssl<file>: <reason>` so users immediately
107
+ * know which option pointed at a bad path.
108
+ *
109
+ * Behaviour-defining details:
110
+ *
111
+ * - `sslrootcert` is only read when `sslMode` is `verify-ca` /
112
+ * `verify-full`. Lower modes (require / prefer / allow) accept the
113
+ * server cert without consulting the trust anchor, matching libpq's
114
+ * policy of never opening the file in those modes. Tests can pass
115
+ * `'verify-ca'` as the sentinel to force the eager read for any
116
+ * non-disable mode (the default if omitted).
117
+ * - `sslpassword` is plumbed into `tls.connect` as `passphrase`; if
118
+ * unset OpenSSL leaves an encrypted key un-decryptable and the
119
+ * handshake errors with a "bad decrypt" diagnostic.
120
+ *
121
+ * Exported for tests (`tls.test.ts` swaps in a mocked `tls.connect`).
122
+ */
123
+ export async function loadTlsFileOptions(tlsOpts, fileOpts, sslMode) {
124
+ const merged = { ...tlsOpts };
125
+ // libpq only opens the trust-anchor file in modes that actually
126
+ // validate the chain. Mirror that so a stale / placeholder
127
+ // `sslrootcert=` doesn't blow up sslmode=require connections.
128
+ const needsRootCert = sslMode === undefined ||
129
+ sslMode === 'verify-ca' ||
130
+ sslMode === 'verify-full';
131
+ if (needsRootCert &&
132
+ fileOpts.sslrootcert !== undefined &&
133
+ fileOpts.sslrootcert !== '') {
134
+ if (fileOpts.sslrootcert === 'system') {
135
+ // `sslrootcert=system`: use the OS / OpenSSL trust store instead of a
136
+ // file. libpq's OpenSSL build honours OpenSSL's $SSL_CERT_FILE (a single
137
+ // bundle) and $SSL_CERT_DIR (a directory of hashed CA files); we read
138
+ // both. With neither set, leaving `ca` unset makes Node fall back to its
139
+ // built-in root store. `rejectUnauthorized` (set by the connection layer
140
+ // for verify-* modes) is left intact.
141
+ const systemCas = [];
142
+ const sslCertFile = process.env.SSL_CERT_FILE;
143
+ if (sslCertFile !== undefined && sslCertFile !== '') {
144
+ systemCas.push(await readPem('sslrootcert', sslCertFile, 'CERTIFICATE'));
145
+ }
146
+ const sslCertDir = process.env.SSL_CERT_DIR;
147
+ if (sslCertDir !== undefined && sslCertDir !== '') {
148
+ systemCas.push(...(await readCaDir(sslCertDir)));
149
+ }
150
+ if (systemCas.length === 1) {
151
+ merged.ca = systemCas[0];
152
+ }
153
+ else if (systemCas.length > 1) {
154
+ merged.ca = systemCas;
155
+ }
156
+ }
157
+ else {
158
+ merged.ca = await readPem('sslrootcert', fileOpts.sslrootcert, 'CERTIFICATE');
159
+ }
160
+ }
161
+ // libpq `sslcertmode` gates whether the client cert/key are sent.
162
+ // - `disable`: skip loading them entirely, even when configured.
163
+ // - `require`: a cert MUST be configured (we only honour an explicit
164
+ // `sslcert`, not libpq's default `~/.postgresql/postgresql.crt`).
165
+ // - `allow` / unset: current behaviour (send when present).
166
+ const certMode = fileOpts.sslcertmode ?? 'allow';
167
+ const clientCert = fileOpts.sslcert !== undefined && fileOpts.sslcert !== ''
168
+ ? fileOpts.sslcert
169
+ : undefined;
170
+ if (certMode === 'require' && clientCert === undefined) {
171
+ throw new Error(`sslcertmode value "require" requires a client certificate`);
172
+ }
173
+ if (certMode !== 'disable') {
174
+ if (clientCert !== undefined) {
175
+ merged.cert = await readPem('sslcert', clientCert, 'CERTIFICATE');
176
+ }
177
+ if (fileOpts.sslkey !== undefined && fileOpts.sslkey !== '') {
178
+ await assertKeyPermissions(fileOpts.sslkey);
179
+ merged.key = await readPem('sslkey', fileOpts.sslkey, 'PRIVATE KEY');
180
+ }
181
+ }
182
+ // CRLs come from a single file (`sslcrl`) and/or every file in a directory
183
+ // (`sslcrldir`). Node's `crl` option accepts an array of PEM buffers, so we
184
+ // collect each source and only set `crl` when at least one was read.
185
+ const crls = [];
186
+ if (fileOpts.sslcrl !== undefined && fileOpts.sslcrl !== '') {
187
+ crls.push(await readPem('sslcrl', fileOpts.sslcrl));
188
+ }
189
+ if (fileOpts.sslcrldir !== undefined && fileOpts.sslcrldir !== '') {
190
+ crls.push(...(await readCrlDir(fileOpts.sslcrldir)));
191
+ }
192
+ if (crls.length === 1) {
193
+ merged.crl = crls[0];
194
+ }
195
+ else if (crls.length > 1) {
196
+ merged.crl = crls;
197
+ }
198
+ // sslpassword is plumbed through verbatim — OpenSSL applies it when it
199
+ // sees an encrypted key. Empty string is "no passphrase" (libpq's
200
+ // convention) so we skip it.
201
+ if (fileOpts.sslpassword !== undefined && fileOpts.sslpassword !== '') {
202
+ merged.passphrase = fileOpts.sslpassword;
203
+ }
204
+ // sslkeylogfile is not a tls.connect option; the keylog listener is wired
205
+ // in `upgradeToTls`. We pre-check it here (the home of file diagnostics)
206
+ // by opening it for append, so an unwritable path fails fast at connect
207
+ // time with `could not open sslkeylogfile "<path>": <reason>` rather than
208
+ // silently dropping keys mid-handshake.
209
+ if (fileOpts.sslkeylogfile !== undefined && fileOpts.sslkeylogfile !== '') {
210
+ await assertKeyLogFileWritable(fileOpts.sslkeylogfile);
211
+ }
212
+ return merged;
213
+ }
214
+ /**
215
+ * Pre-flight the `sslkeylogfile` target by opening it for append (creating
216
+ * it if absent) and immediately closing the handle. Surfaces libpq-style
217
+ * `could not open sslkeylogfile "<path>": <reason>` on any failure (e.g. an
218
+ * unwritable directory) before the handshake starts.
219
+ */
220
+ async function assertKeyLogFileWritable(filePath) {
221
+ try {
222
+ const handle = await fs.open(filePath, 'a');
223
+ await handle.close();
224
+ }
225
+ catch (err) {
226
+ const reason = err instanceof Error ? err.message : String(err);
227
+ throw new Error(`could not open sslkeylogfile "${filePath}": ${reason}`);
228
+ }
229
+ }
230
+ /** True when the bytes already carry a `-----BEGIN ...-----` PEM header. */
231
+ function isPemArmored(bytes) {
232
+ // A PEM file is ASCII text; scan a bounded prefix (skipping leading
233
+ // whitespace libpq tolerates) for the armor marker. DER is binary and will
234
+ // not contain this token at the front.
235
+ const head = bytes.subarray(0, 64).toString('latin1');
236
+ return head.includes('-----BEGIN');
237
+ }
238
+ /**
239
+ * Wrap raw DER bytes in the requested PEM armor: base64 the DER, split into
240
+ * 64-char lines (the PEM convention), and bracket with the BEGIN/END markers.
241
+ */
242
+ function derToPem(der, armor) {
243
+ const b64 = der.toString('base64');
244
+ const lines = b64.match(/.{1,64}/g) ?? [];
245
+ const body = lines.join('\n');
246
+ const pem = `-----BEGIN ${armor}-----\n${body}\n-----END ${armor}-----\n`;
247
+ return Buffer.from(pem, 'ascii');
248
+ }
249
+ /**
250
+ * Convert a DER-encoded private key to canonical PKCS#8 PEM, the way libpq
251
+ * relies on OpenSSL to sniff `sslkey` format. A DER key may be PKCS#8
252
+ * (`PrivateKeyInfo`), PKCS#1 (bare RSA `RSAPrivateKey`), or SEC1 (bare EC
253
+ * `ECPrivateKey`) — and the encoding `openssl pkey -outform der` produces for
254
+ * RSA differs across OpenSSL versions (3.0.x emits a form that blind PKCS#8
255
+ * armor cannot load: `DECODER routines::unsupported`). Rather than guess the
256
+ * armor, let `crypto.createPrivateKey` decode each candidate type and
257
+ * re-export a single canonical PKCS#8 PEM that `tls.connect` always accepts.
258
+ */
259
+ function derPrivateKeyToPem(der) {
260
+ let lastErr;
261
+ for (const type of ['pkcs8', 'pkcs1', 'sec1']) {
262
+ try {
263
+ const key = createPrivateKey({ key: der, format: 'der', type });
264
+ const pem = key.export({ format: 'pem', type: 'pkcs8' });
265
+ return typeof pem === 'string' ? Buffer.from(pem, 'ascii') : pem;
266
+ }
267
+ catch (err) {
268
+ lastErr = err;
269
+ }
270
+ }
271
+ const reason = lastErr instanceof Error ? lastErr.message : String(lastErr);
272
+ throw new Error(`sslkey is DER but could not be decoded as PKCS#8, PKCS#1, or SEC1: ${reason}`);
273
+ }
274
+ /**
275
+ * Read a libpq SSL file, returning PEM bytes ready for `tls.connect`. If the
276
+ * file is already PEM-armored it's returned verbatim; otherwise it's treated
277
+ * as DER and converted in-memory using {@link derToPem} with `derArmor`
278
+ * (matching libpq's PEM-or-DER auto-detection). When `derArmor` is omitted the
279
+ * file is returned as-is even if not PEM (used for CRLs, where DER conversion
280
+ * is out of scope).
281
+ */
282
+ async function readPem(label, filePath, derArmor) {
283
+ let bytes;
284
+ try {
285
+ bytes = await fs.readFile(filePath);
286
+ }
287
+ catch (err) {
288
+ const reason = err instanceof Error ? err.message : String(err);
289
+ throw new Error(`could not read ${label} "${filePath}": ${reason}`);
290
+ }
291
+ if (derArmor !== undefined && !isPemArmored(bytes)) {
292
+ // Private keys need format-aware decoding (PKCS#8 / PKCS#1 / SEC1);
293
+ // certs are a single ASN.1 shape and wrap directly.
294
+ return derArmor === 'PRIVATE KEY'
295
+ ? derPrivateKeyToPem(bytes)
296
+ : derToPem(bytes, derArmor);
297
+ }
298
+ return bytes;
299
+ }
300
+ /**
301
+ * libpq-style permission guard for the client private key (`sslkey`). libpq
302
+ * (`fe-secure-openssl.c`) `stat()`s the key file and refuses to load it when
303
+ * it is a regular file with any group or world access bits set, unless it is
304
+ * root-owned with at most `u=rw,g=r` (0640). Mirroring that keeps an
305
+ * accidentally world-readable key from being used silently.
306
+ *
307
+ * The check is a no-op on Windows, where the POSIX mode bits are not
308
+ * meaningful (matching libpq, which `#ifndef WIN32`-guards the same check),
309
+ * and when the key path is a directory / special file (only regular files
310
+ * carry a private key here).
311
+ */
312
+ async function assertKeyPermissions(keyPath) {
313
+ if (process.platform === 'win32')
314
+ return;
315
+ let stat;
316
+ try {
317
+ stat = await fs.stat(keyPath);
318
+ }
319
+ catch (err) {
320
+ const reason = err instanceof Error ? err.message : String(err);
321
+ throw new Error(`could not read sslkey "${keyPath}": ${reason}`);
322
+ }
323
+ if (!stat.isFile())
324
+ return;
325
+ // Low 9 mode bits: rwx for user/group/other.
326
+ const mode = stat.mode & 0o777;
327
+ const groupOrWorld = mode & 0o077;
328
+ if (groupOrWorld === 0)
329
+ return;
330
+ // Root-owned keys are allowed to be u=rw,g=r (0640) or less, matching
331
+ // libpq's relaxed allowance for system-managed keys: no bits outside the
332
+ // 0640 mask may be set.
333
+ if (stat.uid === 0 && (mode & ~0o640) === 0) {
334
+ return;
335
+ }
336
+ throw new Error(`private key file "${keyPath}" has group or world access`);
337
+ }
338
+ /**
339
+ * Read every regular file in an `sslcrldir` directory and return their PEM
340
+ * bytes. Subdirectories are skipped (libpq's c_rehash-style directory only
341
+ * holds hashed CRL files). A failure to list the directory or read any file
342
+ * surfaces with the `sslcrldir` label so the caller sees which option was
343
+ * misconfigured.
344
+ */
345
+ async function readCrlDir(dirPath) {
346
+ let entries;
347
+ try {
348
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
349
+ }
350
+ catch (err) {
351
+ const reason = err instanceof Error ? err.message : String(err);
352
+ throw new Error(`could not read sslcrldir "${dirPath}": ${reason}`);
353
+ }
354
+ const out = [];
355
+ for (const entry of entries) {
356
+ if (!entry.isFile())
357
+ continue;
358
+ out.push(await readPem('sslcrldir', path.join(dirPath, entry.name)));
359
+ }
360
+ return out;
361
+ }
362
+ /**
363
+ * Read every regular file in an OpenSSL `$SSL_CERT_DIR` (the hashed-dir
364
+ * convention honoured by `sslrootcert=system`) and return their PEM bytes.
365
+ * Mirrors {@link readCrlDir}: subdirectories are skipped and a DER-format
366
+ * file is auto-converted to PEM. A failure to list the directory or read any
367
+ * file surfaces as `could not read SSL_CERT_DIR "<path>": <reason>`.
368
+ *
369
+ * Exported for tests.
370
+ */
371
+ export async function readCaDir(dirPath) {
372
+ let entries;
373
+ try {
374
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
375
+ }
376
+ catch (err) {
377
+ const reason = err instanceof Error ? err.message : String(err);
378
+ throw new Error(`could not read SSL_CERT_DIR "${dirPath}": ${reason}`);
379
+ }
380
+ const out = [];
381
+ for (const entry of entries) {
382
+ if (!entry.isFile())
383
+ continue;
384
+ out.push(await readPem('SSL_CERT_DIR', path.join(dirPath, entry.name), 'CERTIFICATE'));
385
+ }
386
+ return out;
387
+ }
388
+ /**
389
+ * Send SSLRequest and pull off the 1-byte server response. The byte is
390
+ * outside the regular framed protocol (it has no length / type header), so
391
+ * we can't reuse MessageParser; instead we peel off one byte and push any
392
+ * remainder back onto the socket via `unshift`.
393
+ *
394
+ * Exported for tests.
395
+ */
396
+ export function sendSslRequest(socket) {
397
+ return new Promise((resolve, reject) => {
398
+ const onError = (err) => {
399
+ cleanup();
400
+ reject(err);
401
+ };
402
+ const onData = (chunk) => {
403
+ if (chunk.length === 0)
404
+ return;
405
+ const first = String.fromCharCode(chunk[0]);
406
+ if (first !== 'S' && first !== 'N') {
407
+ cleanup();
408
+ reject(new Error(`Unexpected SSLRequest response byte 0x${chunk[0].toString(16)}`));
409
+ return;
410
+ }
411
+ // Any extra bytes belong to subsequent messages; push them back.
412
+ if (chunk.length > 1) {
413
+ socket.unshift(chunk.subarray(1));
414
+ }
415
+ cleanup();
416
+ resolve(first);
417
+ };
418
+ const cleanup = () => {
419
+ socket.removeListener('data', onData);
420
+ socket.removeListener('error', onError);
421
+ };
422
+ socket.on('data', onData);
423
+ socket.on('error', onError);
424
+ socket.write(SSLRequest());
425
+ });
426
+ }
427
+ /**
428
+ * Translate a Node/OpenSSL TLS handshake error into libpq-style wording so
429
+ * our diagnostics match upstream psql/libpq exactly (the cases asserted by
430
+ * upstream `001_ssltests.pl`). Unrecognised errors pass through unchanged.
431
+ * The original error is preserved on `cause` for callers that introspect.
432
+ *
433
+ * - Chain-verification failures (`ERR_TLS_CERT_ALTNAME_INVALID` excluded)
434
+ * → `certificate verify failed` (libpq's `SSL error: certificate verify
435
+ * failed`).
436
+ * - Hostname mismatch (`ERR_TLS_CERT_ALTNAME_INVALID`, Node's
437
+ * "Hostname/IP does not match certificate's altnames") →
438
+ * `server certificate for "<host>" does not match host name "<host>"`.
439
+ * - Encrypted-key decrypt failures (`ERR_OSSL_BAD_DECRYPT`, or an OpenSSL
440
+ * message containing `bad decrypt`) → libpq's
441
+ * `could not load private key file "<path>": <openssl text>` shape. The
442
+ * raw OpenSSL text (which carries the `bad decrypt` token upstream's
443
+ * `001_ssltests.pl` matches on) is preserved in the message tail. When the
444
+ * key path is unknown the path segment is omitted but the `bad decrypt`
445
+ * token is still surfaced.
446
+ *
447
+ * `keyPath`, when supplied, is the configured `sslkey` path — used only to
448
+ * fill libpq's `private key file "<path>"` phrasing on a decrypt failure.
449
+ *
450
+ * Exported for unit tests.
451
+ */
452
+ export function mapTlsHandshakeError(err, servername, keyPath) {
453
+ const code = err.code;
454
+ const msg = err.message;
455
+ // Encrypted client-key decrypt failure (wrong / missing `sslpassword`).
456
+ // OpenSSL throws this synchronously out of `tls.connect`; reshape it to
457
+ // libpq's `could not load private key file "<path>": ... bad decrypt`.
458
+ if (code === 'ERR_OSSL_BAD_DECRYPT' || /bad decrypt/i.test(msg)) {
459
+ const where = keyPath !== undefined && keyPath !== ''
460
+ ? `private key file "${keyPath}"`
461
+ : 'private key file';
462
+ const mapped = new Error(`could not load ${where}: ${msg}`);
463
+ mapped.cause = err;
464
+ return mapped;
465
+ }
466
+ if (code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
467
+ const host = servername ?? '';
468
+ const mapped = new Error(`server certificate for "${host}" does not match host name "${host}"`);
469
+ mapped.cause = err;
470
+ return mapped;
471
+ }
472
+ // OpenSSL chain-verification failures surface with a `code` like
473
+ // `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `DEPTH_ZERO_SELF_SIGNED_CERT`,
474
+ // `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED`, etc. libpq collapses
475
+ // them all to `certificate verify failed`.
476
+ const isVerifyFailure = code !== undefined &&
477
+ code !== 'ERR_TLS_CERT_ALTNAME_INVALID' &&
478
+ /CERT|SIGNATURE|SELF_SIGNED|UNABLE_TO|CHAIN|EXPIRED|NOT_YET_VALID|INVALID_CA/.test(code);
479
+ if (isVerifyFailure || /certificate verify failed/i.test(msg)) {
480
+ const mapped = new Error('certificate verify failed');
481
+ mapped.cause = err;
482
+ return mapped;
483
+ }
484
+ return err;
485
+ }
486
+ /** Pull the SNI `servername` from the TLS options for error messages. */
487
+ function getServername(tlsOpts) {
488
+ return typeof tlsOpts.servername === 'string'
489
+ ? tlsOpts.servername
490
+ : undefined;
491
+ }
492
+ function upgradeToTls(socket, tlsOpts, sslkeylogfile, keyPath,
493
+ // Direct SSL (sslnegotiation=direct, PG17+) REQUIRES the server to select
494
+ // ALPN `postgresql` — a protocol-confusion defense, since there is no
495
+ // SSLRequest probe to confirm a postgres endpoint. libpq aborts when the
496
+ // negotiated ALPN isn't `postgresql`; we mirror that (review item #9).
497
+ requireAlpn = false) {
498
+ return new Promise((resolve, reject) => {
499
+ let tlsSocket;
500
+ const cleanup = () => {
501
+ tlsSocket?.removeListener('error', onError);
502
+ };
503
+ const onError = (err) => {
504
+ cleanup();
505
+ reject(mapTlsHandshakeError(err, getServername(tlsOpts), keyPath));
506
+ };
507
+ // OpenSSL surfaces an un-decryptable client key (wrong / missing
508
+ // `sslpassword`) by throwing synchronously out of `tls.connect` rather
509
+ // than emitting `'error'`. Catch it here so it flows through the same
510
+ // libpq-wording mapper as asynchronous handshake failures.
511
+ try {
512
+ tlsSocket = tls.connect({
513
+ ...tlsOpts,
514
+ socket,
515
+ }, () => {
516
+ cleanup();
517
+ // `tlsSocket` is always assigned by the time this async handshake
518
+ // callback fires (tls.connect returns it synchronously above); the
519
+ // guard simply narrows the `| undefined` for the type checker.
520
+ const established = tlsSocket;
521
+ if (established === undefined)
522
+ return;
523
+ // Enforce the mandatory `postgresql` ALPN for direct SSL. Without
524
+ // it a TLS terminator / HTTPS proxy with a valid host cert would be
525
+ // accepted and the startup packet sent in the blind (review #9).
526
+ if (requireAlpn && established.alpnProtocol !== 'postgresql') {
527
+ cleanup();
528
+ established.destroy();
529
+ reject(new Error('direct SSL connection requires ALPN, but the server did ' +
530
+ 'not negotiate the "postgresql" protocol'));
531
+ return;
532
+ }
533
+ let channelBindingData = null;
534
+ try {
535
+ // Prefer the modern X509Certificate API (Node 15.6+): it returns a
536
+ // proper `X509Certificate` instance whose `.raw` is the
537
+ // DER-encoded cert. Falls back to the legacy
538
+ // `getPeerCertificate(true)` for compatibility.
539
+ const x509 = established.getPeerX509Certificate?.();
540
+ if (x509?.raw && x509.raw.length > 0) {
541
+ channelBindingData = createHash('sha256')
542
+ .update(x509.raw)
543
+ .digest();
544
+ }
545
+ else {
546
+ // `detailed = true` gets us the full peer cert chain. Some
547
+ // Node/OpenSSL combinations leave `.raw` undefined on the legacy
548
+ // API when `rejectUnauthorized: false`; in that case we have to
549
+ // accept that channel binding is unavailable.
550
+ const peerCert = established.getPeerCertificate(true);
551
+ if (peerCert?.raw && peerCert.raw.length > 0) {
552
+ channelBindingData = computeChannelBindingData(peerCert);
553
+ }
554
+ }
555
+ }
556
+ catch {
557
+ // Best-effort: a missing peer cert => no channel binding. SASL
558
+ // path will fall back to SCRAM-SHA-256 (non-PLUS).
559
+ channelBindingData = null;
560
+ }
561
+ resolve({ kind: 'tls', socket: established, channelBindingData });
562
+ });
563
+ }
564
+ catch (err) {
565
+ reject(mapTlsHandshakeError(err instanceof Error ? err : new Error(String(err)), getServername(tlsOpts), keyPath));
566
+ return;
567
+ }
568
+ tlsSocket.on('error', onError);
569
+ // libpq `sslkeylogfile`: append each emitted key-log line so the
570
+ // handshake can be decrypted offline. The path was pre-checked for
571
+ // writability in loadTlsFileOptions.
572
+ if (sslkeylogfile !== undefined && sslkeylogfile !== '') {
573
+ attachKeyLogListener(tlsSocket, sslkeylogfile);
574
+ }
575
+ });
576
+ }
577
+ /**
578
+ * Wire a TLSSocket's `'keylog'` event to append each emitted key-log line to
579
+ * `filePath`. Node emits one already-newline-terminated Buffer per event. A
580
+ * write that fails after the pre-check (e.g. the directory was removed
581
+ * mid-session) is re-emitted as a socket `'error'` rather than crashing the
582
+ * process.
583
+ *
584
+ * Accepts a minimal event-emitter shape so it can be unit-tested against a
585
+ * fake socket without a real TLS handshake. Exported for tests.
586
+ */
587
+ export function attachKeyLogListener(socket, filePath) {
588
+ socket.on('keylog', (line) => {
589
+ try {
590
+ appendFileSync(filePath, line);
591
+ }
592
+ catch (err) {
593
+ socket.emit('error', err instanceof Error ? err : new Error(String(err)));
594
+ }
595
+ });
596
+ }
@@ -50,6 +50,7 @@ export const test = originalTest.extend({
50
50
  stdio: 'pipe',
51
51
  env: {
52
52
  PATH: `mocks/bin:${process.env.PATH}`,
53
+ ...(options.env ?? {}),
53
54
  },
54
55
  });
55
56
  return new Promise((resolve, reject) => {