neonctl 2.22.0 → 2.23.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/README.md +242 -16
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/checkout.js +249 -0
- package/commands/connection_string.js +15 -2
- package/commands/data_api.js +286 -0
- package/commands/functions.js +277 -0
- package/commands/index.js +12 -0
- package/commands/link.js +667 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +62 -0
- package/commands/set_context.js +7 -2
- package/context.js +86 -14
- package/functions_api.js +44 -0
- package/index.js +3 -0
- package/package.json +60 -51
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/enrichers.js +18 -1
- package/utils/esbuild.js +147 -0
- package/utils/middlewares.js +1 -1
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/connection_string.test.js +0 -196
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
package/psql/index.js
ADDED
|
@@ -0,0 +1,2005 @@
|
|
|
1
|
+
import { PgConnection } from './wire/connection.js';
|
|
2
|
+
import { applyEnvOverrides, defaultSettings } from './core/settings.js';
|
|
3
|
+
import { createVarStore } from './core/variables.js';
|
|
4
|
+
import { syncConnectionVars } from './core/syncVars.js';
|
|
5
|
+
import { defaultRegistry } from './command/dispatch.js';
|
|
6
|
+
import { createCondStack } from './command/cmd_cond.js';
|
|
7
|
+
import { runMainLoop, EXIT_BADCONN, EXIT_FAILURE, EXIT_SUCCESS, EXIT_USER, } from './core/mainloop.js';
|
|
8
|
+
import { applyStartupArgs, parseStartupArgs } from './core/startup.js';
|
|
9
|
+
import { executeInputString, loadPsqlrc } from './io/psqlrc.js';
|
|
10
|
+
import { loadPgPass } from './io/pgpass.js';
|
|
11
|
+
import { loadPgServices } from './io/pgservice.js';
|
|
12
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
/**
|
|
16
|
+
* Embedded TypeScript psql entrypoint.
|
|
17
|
+
*
|
|
18
|
+
* Argv shape mirrors the legacy native-psql call site:
|
|
19
|
+
* argv[0] = connection URI (postgresql://user:pw@host:port/db?sslmode=...)
|
|
20
|
+
* OR an empty string `''` when the caller provides the
|
|
21
|
+
* connection target via libpq flags (-h/-p/-U/-d) and/or PG*
|
|
22
|
+
* env. The downstream layered resolver picks those up just like
|
|
23
|
+
* upstream psql does.
|
|
24
|
+
* argv[1..] = forwarded psql args (parsed by `parseStartupArgs`).
|
|
25
|
+
*/
|
|
26
|
+
export const runPsql = async (argv, stdio = {}) => {
|
|
27
|
+
const stdin = stdio.stdin ?? process.stdin;
|
|
28
|
+
const stdout = stdio.stdout ?? process.stdout;
|
|
29
|
+
const stderr = stdio.stderr ?? process.stderr;
|
|
30
|
+
const connectionUri = argv[0] ?? '';
|
|
31
|
+
// Parse argv[0] in one of three shapes:
|
|
32
|
+
// - URI scheme (`postgres://…` / `postgresql://…`): the URI-partial
|
|
33
|
+
// parser handles authority, query, and `?service=…`.
|
|
34
|
+
// - libpq conninfo string (`key=value …`, no scheme): `parseConninfo`
|
|
35
|
+
// extracts each known key (including `service`).
|
|
36
|
+
// - Bare database name (e.g. `mydb`): no parsing; the rest of the
|
|
37
|
+
// resolver picks up host/port/user/etc. from env/pgpass/service/
|
|
38
|
+
// defaults.
|
|
39
|
+
//
|
|
40
|
+
// `looksLikeConnectionString` (libpq parity: `recognized_connection_
|
|
41
|
+
// string()`) decides between the first two and the third.
|
|
42
|
+
//
|
|
43
|
+
// When `connectionUri` is empty (the standalone-psql shim case), we
|
|
44
|
+
// skip parsing entirely and rely on libpq flags + env to populate the
|
|
45
|
+
// ConnectOptions layers.
|
|
46
|
+
let uriPartial = {};
|
|
47
|
+
let uriService;
|
|
48
|
+
if (connectionUri !== '' && looksLikeConnectionString(connectionUri)) {
|
|
49
|
+
try {
|
|
50
|
+
if (connectionUri.startsWith('postgres://') ||
|
|
51
|
+
connectionUri.startsWith('postgresql://')) {
|
|
52
|
+
uriPartial = parseConnectionUriPartial(connectionUri);
|
|
53
|
+
uriService = parseConnectionUriService(connectionUri);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Bare `key=value …` conninfo string. `parseConninfo` parks the
|
|
57
|
+
// service name on a private `_service` staging slot (it's not
|
|
58
|
+
// part of ConnectOptions); pull it out so the layered resolver
|
|
59
|
+
// can look it up.
|
|
60
|
+
const parsed = parseConninfo(connectionUri);
|
|
61
|
+
if (typeof parsed._service === 'string' && parsed._service.length > 0) {
|
|
62
|
+
uriService = parsed._service;
|
|
63
|
+
}
|
|
64
|
+
delete parsed._service;
|
|
65
|
+
uriPartial = parsed;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
stderr.write(`psql: error: ${err.message}\n`);
|
|
70
|
+
return EXIT_BADCONN;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Parse psql args (argv[1..]). argv[0] is the connection URI consumed above.
|
|
74
|
+
const parsed = parseStartupArgs(argv.slice(1));
|
|
75
|
+
if ('kind' in parsed) {
|
|
76
|
+
if (parsed.kind === 'help' || parsed.kind === 'version') {
|
|
77
|
+
stdout.write(parsed.message);
|
|
78
|
+
if (!parsed.message.endsWith('\n'))
|
|
79
|
+
stdout.write('\n');
|
|
80
|
+
return EXIT_SUCCESS;
|
|
81
|
+
}
|
|
82
|
+
stderr.write(`psql: error: ${parsed.message}\n`);
|
|
83
|
+
return EXIT_FAILURE;
|
|
84
|
+
}
|
|
85
|
+
const vars = createVarStore();
|
|
86
|
+
const settings = defaultSettings(vars);
|
|
87
|
+
applyEnvOverrides(settings, process.env);
|
|
88
|
+
// Track interactive-ness from the actual stdin we'll read.
|
|
89
|
+
settings.notty = !stdin.isTTY;
|
|
90
|
+
// Resolve external configuration sources (pgpass, pg_service.conf) before
|
|
91
|
+
// running the layered merge. `loadPgPass` always degrades silently to
|
|
92
|
+
// an empty result. `loadPgServices` only errors when the user named a
|
|
93
|
+
// missing file via `$PGSERVICEFILE` (libpq parity for `006_service.pl`);
|
|
94
|
+
// bubble that out as a connection error.
|
|
95
|
+
const pgpassEntries = await loadPgPass(undefined, {
|
|
96
|
+
env: process.env,
|
|
97
|
+
stderr,
|
|
98
|
+
});
|
|
99
|
+
let services;
|
|
100
|
+
try {
|
|
101
|
+
services = await loadPgServices();
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
stderr.write(`psql: error: ${err.message}\n`);
|
|
105
|
+
return EXIT_BADCONN;
|
|
106
|
+
}
|
|
107
|
+
let resolved;
|
|
108
|
+
try {
|
|
109
|
+
resolved = applyStartupArgs(parsed, settings, undefined, {
|
|
110
|
+
env: process.env,
|
|
111
|
+
uriPartial,
|
|
112
|
+
serviceName: uriService,
|
|
113
|
+
pgpassEntries,
|
|
114
|
+
services,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// `resolveLayeredConnect` throws on unknown service name (libpq
|
|
119
|
+
// parity). Surface as a connection-setup error and bail.
|
|
120
|
+
stderr.write(`psql: error: ${err.message}\n`);
|
|
121
|
+
return EXIT_BADCONN;
|
|
122
|
+
}
|
|
123
|
+
const { connect: connectOpts, preActions } = resolved;
|
|
124
|
+
let connection;
|
|
125
|
+
try {
|
|
126
|
+
connection = await PgConnection.connect(connectOpts);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const e = err;
|
|
130
|
+
stderr.write(`psql: error: connection to server failed: ${e.message ?? String(err)}\n`);
|
|
131
|
+
return EXIT_BADCONN;
|
|
132
|
+
}
|
|
133
|
+
settings.db = connection;
|
|
134
|
+
// Mirror upstream psql's SyncVariables(): populate the connection-driven
|
|
135
|
+
// psql vars (DBNAME/USER/HOST/PORT/ENCODING/SERVER_VERSION_*) so scripts
|
|
136
|
+
// can interpolate `:DBNAME`, `:USER`, etc. from the first prompt onward.
|
|
137
|
+
syncConnectionVars(settings.vars, connection);
|
|
138
|
+
const registry = defaultRegistry();
|
|
139
|
+
const cond = createCondStack();
|
|
140
|
+
const ctx = {
|
|
141
|
+
settings,
|
|
142
|
+
registry,
|
|
143
|
+
cond,
|
|
144
|
+
stdin,
|
|
145
|
+
stdout,
|
|
146
|
+
stderr,
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
// Startup banner — mirrors upstream psql's
|
|
150
|
+
// psql (<client>, server <server>)
|
|
151
|
+
// SSL connection (protocol: …, cipher: …)
|
|
152
|
+
// Type "help" for help.
|
|
153
|
+
// Suppressed in quiet mode and when stdin isn't a TTY (scripted use).
|
|
154
|
+
if (!settings.quiet && !settings.notty && preActions.length === 0) {
|
|
155
|
+
writeStartupBanner(connection, stdout);
|
|
156
|
+
}
|
|
157
|
+
// Run .psqlrc unless -X was specified.
|
|
158
|
+
await loadPsqlrc(ctx, { skip: parsed.noPsqlrc, env: process.env });
|
|
159
|
+
// If the user supplied -c / -f actions, execute them sequentially and
|
|
160
|
+
// exit (mirrors upstream psql behaviour). Otherwise, fall through to
|
|
161
|
+
// the REPL.
|
|
162
|
+
if (preActions.length > 0) {
|
|
163
|
+
return await runPreActions(ctx, preActions, parsed.singleTransaction);
|
|
164
|
+
}
|
|
165
|
+
return await runMainLoop(ctx);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
try {
|
|
169
|
+
await connection.close();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// ignore close errors
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Execute the ordered list of `-c` / `-f` actions and return the upstream
|
|
178
|
+
* psql exit code.
|
|
179
|
+
*
|
|
180
|
+
* Upstream `process_psqlrc_and_targets()` (in `startup.c`) plus the
|
|
181
|
+
* dispatcher in `MainLoop()` cooperate to give each switch one of three
|
|
182
|
+
* outcomes:
|
|
183
|
+
*
|
|
184
|
+
* - `-c "SQL or \backslash"`:
|
|
185
|
+
* * a client-side failure (e.g. bad `\copy`) marks the SWITCH itself
|
|
186
|
+
* as failed → the overall exit code is non-zero, even when
|
|
187
|
+
* ON_ERROR_STOP is off and the transaction commits.
|
|
188
|
+
* * a server-side failure follows the same rule.
|
|
189
|
+
*
|
|
190
|
+
* - `-f file`:
|
|
191
|
+
* * `process_file()` returns a success status by default, so a
|
|
192
|
+
* failing statement inside the file does NOT bubble up to the
|
|
193
|
+
* outer exit code (without ON_ERROR_STOP). Only an I/O failure
|
|
194
|
+
* opening the file flips the switch status.
|
|
195
|
+
*
|
|
196
|
+
* - `--single-transaction`:
|
|
197
|
+
* * before the FIRST action, issue `BEGIN`. After the LAST action,
|
|
198
|
+
* issue `COMMIT` (success) or `ROLLBACK` (when ON_ERROR_STOP fired
|
|
199
|
+
* and we stopped early).
|
|
200
|
+
* * Without ON_ERROR_STOP, the transaction commits even when some
|
|
201
|
+
* individual statements failed — the failing statements only
|
|
202
|
+
* influence the exit code (see the `-c` / `-f` distinction above).
|
|
203
|
+
*/
|
|
204
|
+
const runPreActions = async (ctx, preActions, singleTransaction) => {
|
|
205
|
+
const { settings, stderr } = ctx;
|
|
206
|
+
let status = EXIT_SUCCESS;
|
|
207
|
+
let beganTransaction = false;
|
|
208
|
+
let earlyStopOnError = false;
|
|
209
|
+
let connectionLost = false;
|
|
210
|
+
// --single-transaction: wrap the entire batch in BEGIN ... COMMIT/ROLLBACK.
|
|
211
|
+
// We do this with `db.execSimple` directly so the wrapper does not itself
|
|
212
|
+
// count as a "failed switch" for exit-code purposes.
|
|
213
|
+
if (singleTransaction && settings.db) {
|
|
214
|
+
try {
|
|
215
|
+
await settings.db.execSimple('BEGIN');
|
|
216
|
+
beganTransaction = true;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
220
|
+
stderr.write(`psql: error: could not begin transaction: ${msg}\n`);
|
|
221
|
+
return EXIT_FAILURE;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const action of preActions) {
|
|
225
|
+
if (connectionLost)
|
|
226
|
+
break;
|
|
227
|
+
if (action.kind === 'command') {
|
|
228
|
+
let outcome;
|
|
229
|
+
try {
|
|
230
|
+
outcome = await executeInputString(action.sql, ctx, { print: true });
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
// Defensive: executeInputString shouldn't throw, but if a downstream
|
|
234
|
+
// command bubbles an exception we still want to surface it as a
|
|
235
|
+
// failed switch rather than crashing.
|
|
236
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
237
|
+
stderr.write(`psql: ERROR: ${msg}\n`);
|
|
238
|
+
status = EXIT_FAILURE;
|
|
239
|
+
if (settings.onErrorStop) {
|
|
240
|
+
earlyStopOnError = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (outcome.connectionLost) {
|
|
246
|
+
connectionLost = true;
|
|
247
|
+
status = EXIT_BADCONN;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
// For `-c`: any failure flips the exit code to EXIT_FAILURE (1) — real
|
|
251
|
+
// psql exits 1 for a `-c` error regardless of ON_ERROR_STOP, NOT the
|
|
252
|
+
// EXIT_USER (3) used for ON_ERROR_STOP aborts in script/stdin mode
|
|
253
|
+
// (verified on psql 18.4). The `\copy` errors are on stderr.
|
|
254
|
+
if (outcome.hadError || outcome.stoppedOnError) {
|
|
255
|
+
status = EXIT_FAILURE;
|
|
256
|
+
}
|
|
257
|
+
if (outcome.stoppedOnError) {
|
|
258
|
+
earlyStopOnError = true;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// -f file: I/O failure (missing file, permission denied) is a hard
|
|
264
|
+
// EXIT_FAILURE on the switch. Per-statement failures inside the file
|
|
265
|
+
// are SWALLOWED for exit-code purposes (mirrors `process_file()` in
|
|
266
|
+
// upstream, which only escalates to a stop when ON_ERROR_STOP fires).
|
|
267
|
+
let contents;
|
|
268
|
+
try {
|
|
269
|
+
contents = await fs.readFile(action.path, 'utf8');
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
273
|
+
stderr.write(`psql: error: ${msg}\n`);
|
|
274
|
+
status = EXIT_FAILURE;
|
|
275
|
+
if (settings.onErrorStop) {
|
|
276
|
+
earlyStopOnError = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
let outcome;
|
|
282
|
+
try {
|
|
283
|
+
outcome = await executeInputString(contents, ctx, { print: true });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
287
|
+
stderr.write(`psql: ERROR: ${msg}\n`);
|
|
288
|
+
status = EXIT_USER;
|
|
289
|
+
if (settings.onErrorStop) {
|
|
290
|
+
earlyStopOnError = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (outcome.connectionLost) {
|
|
296
|
+
connectionLost = true;
|
|
297
|
+
status = EXIT_BADCONN;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
if (outcome.stoppedOnError) {
|
|
301
|
+
status = EXIT_USER;
|
|
302
|
+
earlyStopOnError = true;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
// Per-statement errors inside the file do NOT propagate to the outer
|
|
306
|
+
// exit code (matches upstream `process_file` returning success).
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Wrap up the single-transaction envelope. If we stopped early on error
|
|
310
|
+
// and ON_ERROR_STOP fired, roll back. Otherwise commit — upstream commits
|
|
311
|
+
// even when individual statements failed without ON_ERROR_STOP.
|
|
312
|
+
if (beganTransaction && settings.db && !connectionLost) {
|
|
313
|
+
const closing = earlyStopOnError ? 'ROLLBACK' : 'COMMIT';
|
|
314
|
+
try {
|
|
315
|
+
await settings.db.execSimple(closing);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
// If COMMIT fails the data is gone; surface it but don't override an
|
|
319
|
+
// existing error status. If we tried to COMMIT cleanly and that
|
|
320
|
+
// failed, escalate to EXIT_FAILURE so the caller knows the batch
|
|
321
|
+
// didn't go through.
|
|
322
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
323
|
+
stderr.write(`psql: error: ${closing} failed: ${msg}\n`);
|
|
324
|
+
if (status === EXIT_SUCCESS)
|
|
325
|
+
status = EXIT_FAILURE;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return status;
|
|
329
|
+
};
|
|
330
|
+
const writeStartupBanner = (connection, out) => {
|
|
331
|
+
const serverVersion = connection.parameterStatus('server_version') ?? 'unknown';
|
|
332
|
+
// Client identifier. Matches upstream's `psql (18.4, server X.Y)` shape
|
|
333
|
+
// but signals that this is the embedded TS implementation so users can tell
|
|
334
|
+
// when they're on the fallback path.
|
|
335
|
+
out.write(`psql-ts (neonctl, server ${serverVersion})\n`);
|
|
336
|
+
const tls = connection.getTlsInfo();
|
|
337
|
+
if (tls) {
|
|
338
|
+
const parts = [
|
|
339
|
+
`protocol: ${tls.protocol}`,
|
|
340
|
+
`cipher: ${tls.cipher}`,
|
|
341
|
+
`compression: ${tls.compression}`,
|
|
342
|
+
];
|
|
343
|
+
if (tls.alpn)
|
|
344
|
+
parts.push(`ALPN: ${tls.alpn}`);
|
|
345
|
+
out.write(`SSL connection (${parts.join(', ')})\n`);
|
|
346
|
+
}
|
|
347
|
+
out.write('Type "help" for help.\n\n');
|
|
348
|
+
};
|
|
349
|
+
// Recognized libpq connection parameter keywords (subset matching
|
|
350
|
+
// https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS).
|
|
351
|
+
// We don't necessarily honor every value (e.g. hostaddr, krbsrvname), but we
|
|
352
|
+
// recognize them as valid keys so callers don't get a spurious "unknown key"
|
|
353
|
+
// rejection for a libpq-spec key they expect to work.
|
|
354
|
+
const KNOWN_QUERY_KEYS = new Set([
|
|
355
|
+
'host',
|
|
356
|
+
'hostaddr',
|
|
357
|
+
'port',
|
|
358
|
+
'dbname',
|
|
359
|
+
'user',
|
|
360
|
+
'password',
|
|
361
|
+
'passfile',
|
|
362
|
+
'channel_binding',
|
|
363
|
+
'require_auth',
|
|
364
|
+
'connect_timeout',
|
|
365
|
+
'client_encoding',
|
|
366
|
+
'options',
|
|
367
|
+
'application_name',
|
|
368
|
+
'fallback_application_name',
|
|
369
|
+
'keepalives',
|
|
370
|
+
'keepalives_idle',
|
|
371
|
+
'keepalives_interval',
|
|
372
|
+
'keepalives_count',
|
|
373
|
+
'sslmode',
|
|
374
|
+
'sslnegotiation',
|
|
375
|
+
'sslcompression',
|
|
376
|
+
'sslcert',
|
|
377
|
+
'sslkey',
|
|
378
|
+
'sslcertmode',
|
|
379
|
+
'sslrootcert',
|
|
380
|
+
'sslcrl',
|
|
381
|
+
'sslcrldir',
|
|
382
|
+
'sslkeylogfile',
|
|
383
|
+
'sslsni',
|
|
384
|
+
'requirepeer',
|
|
385
|
+
'ssl_min_protocol_version',
|
|
386
|
+
'ssl_max_protocol_version',
|
|
387
|
+
'krbsrvname',
|
|
388
|
+
'gsslib',
|
|
389
|
+
'gssencmode',
|
|
390
|
+
'service',
|
|
391
|
+
'target_session_attrs',
|
|
392
|
+
'load_balance_hosts',
|
|
393
|
+
'replication',
|
|
394
|
+
]);
|
|
395
|
+
/**
|
|
396
|
+
* Tokenize a postgres connection URI into raw components.
|
|
397
|
+
*
|
|
398
|
+
* Hand-rolled rather than using `new URL()` because libpq accepts shapes
|
|
399
|
+
* the WHATWG URL parser rejects, e.g. `postgresql://user@` (userinfo with
|
|
400
|
+
* no host), `postgres://:12345/` (port-only), `postgres://uri-user@/db`
|
|
401
|
+
* (userinfo with empty host). The upstream conformance suite in
|
|
402
|
+
* `src/interfaces/libpq/t/001_uri.pl` exercises these forms.
|
|
403
|
+
*/
|
|
404
|
+
const tokenizeConnectionUri = (uri) => {
|
|
405
|
+
// Strip scheme. Only postgres:// and postgresql:// are accepted; libpq
|
|
406
|
+
// rejects everything else with a "missing schema" error.
|
|
407
|
+
let rest;
|
|
408
|
+
if (uri.startsWith('postgresql://')) {
|
|
409
|
+
rest = uri.slice('postgresql://'.length);
|
|
410
|
+
}
|
|
411
|
+
else if (uri.startsWith('postgres://')) {
|
|
412
|
+
rest = uri.slice('postgres://'.length);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
throw new Error(`unsupported scheme in URI: ${uri}`);
|
|
416
|
+
}
|
|
417
|
+
// Split off query string.
|
|
418
|
+
let query = '';
|
|
419
|
+
const qIdx = rest.indexOf('?');
|
|
420
|
+
if (qIdx >= 0) {
|
|
421
|
+
query = rest.slice(qIdx + 1);
|
|
422
|
+
rest = rest.slice(0, qIdx);
|
|
423
|
+
}
|
|
424
|
+
// Split off path (database).
|
|
425
|
+
let database;
|
|
426
|
+
const pIdx = rest.indexOf('/');
|
|
427
|
+
if (pIdx >= 0) {
|
|
428
|
+
const pathRaw = rest.slice(pIdx + 1);
|
|
429
|
+
database = pathRaw === '' ? undefined : decodePercent(pathRaw);
|
|
430
|
+
rest = rest.slice(0, pIdx);
|
|
431
|
+
}
|
|
432
|
+
// What's left is the authority: [userinfo@][host[:port]]
|
|
433
|
+
let userinfo;
|
|
434
|
+
const atIdx = rest.lastIndexOf('@');
|
|
435
|
+
if (atIdx >= 0) {
|
|
436
|
+
userinfo = rest.slice(0, atIdx);
|
|
437
|
+
rest = rest.slice(atIdx + 1);
|
|
438
|
+
}
|
|
439
|
+
let user;
|
|
440
|
+
let password;
|
|
441
|
+
if (userinfo !== undefined) {
|
|
442
|
+
const colon = userinfo.indexOf(':');
|
|
443
|
+
if (colon >= 0) {
|
|
444
|
+
user = decodePercent(userinfo.slice(0, colon));
|
|
445
|
+
password = decodePercent(userinfo.slice(colon + 1));
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
user = decodePercent(userinfo);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// hostport: either [ipv6]:port, [ipv6], host:port, or host. With multi-host
|
|
452
|
+
// (libpq 10+), the authority may be a comma-separated list:
|
|
453
|
+
// `h1:5432,h2,[::1]:5433`
|
|
454
|
+
// We split on commas at the top level (i.e. not inside `[...]` IPv6
|
|
455
|
+
// brackets) and parse each segment using the single-host grammar.
|
|
456
|
+
const tuples = splitAuthorityTuples(rest, uri);
|
|
457
|
+
let host;
|
|
458
|
+
let port;
|
|
459
|
+
const hosts = [];
|
|
460
|
+
for (const tuple of tuples) {
|
|
461
|
+
const parsed = parseAuthorityTuple(tuple, uri);
|
|
462
|
+
if (parsed.host !== undefined) {
|
|
463
|
+
hosts.push({ host: parsed.host, port: parsed.port });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (hosts.length > 0) {
|
|
467
|
+
host = hosts[0].host;
|
|
468
|
+
port = hosts[0].port;
|
|
469
|
+
}
|
|
470
|
+
const queryMap = parseQuery(query);
|
|
471
|
+
return {
|
|
472
|
+
user,
|
|
473
|
+
password,
|
|
474
|
+
host,
|
|
475
|
+
port,
|
|
476
|
+
...(hosts.length > 1 ? { hosts } : {}),
|
|
477
|
+
database,
|
|
478
|
+
query: queryMap,
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
482
|
+
* Split a multi-host authority string into one tuple per top-level comma.
|
|
483
|
+
* IPv6 bracket regions are atomic — commas inside `[…]` don't split.
|
|
484
|
+
*
|
|
485
|
+
* Examples:
|
|
486
|
+
* `h1:5432,h2,h3:5434` -> ['h1:5432','h2','h3:5434']
|
|
487
|
+
* `[::1]:5432,[2001:db8::1]` -> ['[::1]:5432','[2001:db8::1]']
|
|
488
|
+
* `h1` -> ['h1']
|
|
489
|
+
* `` -> [''] (single empty tuple — caller may
|
|
490
|
+
* treat as no-host)
|
|
491
|
+
*/
|
|
492
|
+
const splitAuthorityTuples = (rest, uri) => {
|
|
493
|
+
if (rest === '')
|
|
494
|
+
return [''];
|
|
495
|
+
const tuples = [];
|
|
496
|
+
let start = 0;
|
|
497
|
+
let i = 0;
|
|
498
|
+
while (i < rest.length) {
|
|
499
|
+
const ch = rest[i];
|
|
500
|
+
if (ch === '[') {
|
|
501
|
+
const closeIdx = rest.indexOf(']', i);
|
|
502
|
+
if (closeIdx < 0) {
|
|
503
|
+
throw new Error(`missing matching "]" in IPv6 host address: ${uri}`);
|
|
504
|
+
}
|
|
505
|
+
i = closeIdx + 1;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (ch === ',') {
|
|
509
|
+
tuples.push(rest.slice(start, i));
|
|
510
|
+
i += 1;
|
|
511
|
+
start = i;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
i += 1;
|
|
515
|
+
}
|
|
516
|
+
tuples.push(rest.slice(start));
|
|
517
|
+
return tuples;
|
|
518
|
+
};
|
|
519
|
+
const parseAuthorityTuple = (tuple, uri) => {
|
|
520
|
+
if (tuple === '')
|
|
521
|
+
return {};
|
|
522
|
+
if (tuple.startsWith('[')) {
|
|
523
|
+
const closeIdx = tuple.indexOf(']');
|
|
524
|
+
if (closeIdx < 0) {
|
|
525
|
+
throw new Error(`missing matching "]" in IPv6 host address: ${uri}`);
|
|
526
|
+
}
|
|
527
|
+
const host = tuple.slice(1, closeIdx);
|
|
528
|
+
if (host === '') {
|
|
529
|
+
throw new Error(`IPv6 host address may not be empty: ${uri}`);
|
|
530
|
+
}
|
|
531
|
+
const after = tuple.slice(closeIdx + 1);
|
|
532
|
+
if (after === '')
|
|
533
|
+
return { host };
|
|
534
|
+
if (after.startsWith(':')) {
|
|
535
|
+
return { host, port: after.slice(1) };
|
|
536
|
+
}
|
|
537
|
+
throw new Error(`unexpected characters after IPv6 host address in URI: ${uri}`);
|
|
538
|
+
}
|
|
539
|
+
const colon = tuple.indexOf(':');
|
|
540
|
+
if (colon >= 0) {
|
|
541
|
+
return {
|
|
542
|
+
host: decodePercent(tuple.slice(0, colon)),
|
|
543
|
+
port: tuple.slice(colon + 1),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return { host: decodePercent(tuple) };
|
|
547
|
+
};
|
|
548
|
+
const parseQuery = (raw) => {
|
|
549
|
+
const out = new Map();
|
|
550
|
+
if (raw === '')
|
|
551
|
+
return out;
|
|
552
|
+
for (const segment of raw.split('&')) {
|
|
553
|
+
if (segment === '')
|
|
554
|
+
continue;
|
|
555
|
+
const eq = segment.indexOf('=');
|
|
556
|
+
if (eq < 0) {
|
|
557
|
+
// libpq: every query parameter must be `key=value`. Bare keys (no `=`)
|
|
558
|
+
// are rejected. Matches the upstream 001_uri.pl `?zzz` and
|
|
559
|
+
// `?value1&value2` cases.
|
|
560
|
+
throw new Error(`missing "=" after "${segment.trim()}" in connection info string`);
|
|
561
|
+
}
|
|
562
|
+
const keyRaw = segment.slice(0, eq);
|
|
563
|
+
const valueRaw = segment.slice(eq + 1);
|
|
564
|
+
// libpq rejects an extra `=` in either key or value; matches the
|
|
565
|
+
// `?key=key=value` upstream case.
|
|
566
|
+
if (valueRaw.includes('=')) {
|
|
567
|
+
throw new Error(`extra "=" in query parameter "${decodePercent(keyRaw).trim()}"`);
|
|
568
|
+
}
|
|
569
|
+
const key = decodePercent(keyRaw).trim();
|
|
570
|
+
const value = decodePercent(valueRaw).trim();
|
|
571
|
+
if (key === '')
|
|
572
|
+
continue;
|
|
573
|
+
if (!KNOWN_QUERY_KEYS.has(key)) {
|
|
574
|
+
throw new Error(`invalid URI query parameter: "${key}"`);
|
|
575
|
+
}
|
|
576
|
+
out.set(key, value);
|
|
577
|
+
}
|
|
578
|
+
return out;
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* Percent-decode a URI component. libpq strictly validates percent-encoding:
|
|
582
|
+
* - `%XX` must be two hex digits
|
|
583
|
+
* - bare `%` or `%X` is invalid
|
|
584
|
+
* - `%00` is forbidden (NUL bytes can't appear in connection params)
|
|
585
|
+
*
|
|
586
|
+
* `decodeURIComponent` throws URIError on malformed escapes — we surface that
|
|
587
|
+
* as a clear Error. It accepts `%00` (returns `\0`); we explicitly reject.
|
|
588
|
+
*/
|
|
589
|
+
const decodePercent = (s) => {
|
|
590
|
+
let decoded;
|
|
591
|
+
try {
|
|
592
|
+
decoded = decodeURIComponent(s);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
throw new Error(`invalid percent-encoded token in URI: ${s}`);
|
|
596
|
+
}
|
|
597
|
+
if (decoded.includes('\x00')) {
|
|
598
|
+
throw new Error(`forbidden NUL byte (%00) in URI: ${s}`);
|
|
599
|
+
}
|
|
600
|
+
return decoded;
|
|
601
|
+
};
|
|
602
|
+
export const parseConnectionUri = (uri) => {
|
|
603
|
+
const raw = tokenizeConnectionUri(uri);
|
|
604
|
+
// libpq-style: query string can override authority components.
|
|
605
|
+
const queryUser = raw.query.get('user');
|
|
606
|
+
const queryPassword = raw.query.get('password');
|
|
607
|
+
const queryPort = raw.query.get('port');
|
|
608
|
+
const queryDbname = raw.query.get('dbname');
|
|
609
|
+
const queryHost = raw.query.get('host');
|
|
610
|
+
// Multi-host: either from the authority (`h1,h2,h3:5434`) or from
|
|
611
|
+
// `?host=h1,h2,h3&port=5432,5433,5434`. Query-string overrides authority
|
|
612
|
+
// (matching libpq: query params take precedence over URI structural
|
|
613
|
+
// components). Both `host=` and `port=` lists must be the same length OR
|
|
614
|
+
// a single value (broadcast).
|
|
615
|
+
const hostsTuples = computeHostsTuples({
|
|
616
|
+
rawHost: raw.host,
|
|
617
|
+
rawPort: raw.port,
|
|
618
|
+
rawAuthorityHosts: raw.hosts,
|
|
619
|
+
queryHost,
|
|
620
|
+
queryPort,
|
|
621
|
+
});
|
|
622
|
+
// Single-host fallbacks (preserve current behaviour for the `host` / `port`
|
|
623
|
+
// surface — the wire layer prefers `hosts` when set).
|
|
624
|
+
const host = hostsTuples.length > 0 && hostsTuples[0].host !== ''
|
|
625
|
+
? hostsTuples[0].host
|
|
626
|
+
: 'localhost';
|
|
627
|
+
const port = hostsTuples.length > 0 ? hostsTuples[0].port : 5432;
|
|
628
|
+
const user = queryUser !== undefined && queryUser !== ''
|
|
629
|
+
? queryUser
|
|
630
|
+
: raw.user !== undefined && raw.user !== ''
|
|
631
|
+
? raw.user
|
|
632
|
+
: (process.env.USER ?? '');
|
|
633
|
+
const password = queryPassword ?? raw.password;
|
|
634
|
+
const database = queryDbname ?? raw.database ?? user;
|
|
635
|
+
let ssl = normalizeSslMode(raw.query.get('sslmode') ?? null);
|
|
636
|
+
const channelBinding = normalizeChannelBinding(raw.query.get('channel_binding') ?? null);
|
|
637
|
+
// GSSAPI is unsupported (no native Kerberos dep); validate+reject require.
|
|
638
|
+
validateGssEncMode(raw.query.get('gssencmode') ?? null);
|
|
639
|
+
const options = raw.query.get('options');
|
|
640
|
+
// Match upstream psql: default `application_name` to `'psql'` so users see
|
|
641
|
+
// the expected value in `pg_stat_activity`. The neonctl-specific identifier
|
|
642
|
+
// is still discoverable via the User-Agent the protocol layer sends.
|
|
643
|
+
const applicationName = raw.query.get('application_name') ?? 'psql';
|
|
644
|
+
const replication = normalizeReplication(raw.query.get('replication') ?? null);
|
|
645
|
+
const targetSessionAttrs = normalizeTargetSessionAttrs(raw.query.get('target_session_attrs') ?? null);
|
|
646
|
+
const loadBalanceHosts = normalizeLoadBalanceHosts(raw.query.get('load_balance_hosts') ?? null);
|
|
647
|
+
// libpq PEM file paths. Empty string is treated as "not set" so a URI
|
|
648
|
+
// like `?sslcert=` doesn't surface as an attempt to load `""` from disk.
|
|
649
|
+
const sslcert = nonEmpty(raw.query.get('sslcert'));
|
|
650
|
+
const sslkey = nonEmpty(raw.query.get('sslkey'));
|
|
651
|
+
const sslcertmode = normalizeSslCertMode(raw.query.get('sslcertmode') ?? null);
|
|
652
|
+
const sslnegotiation = normalizeSslNegotiation(raw.query.get('sslnegotiation') ?? null);
|
|
653
|
+
const sslrootcert = nonEmpty(raw.query.get('sslrootcert'));
|
|
654
|
+
const sslcrl = nonEmpty(raw.query.get('sslcrl'));
|
|
655
|
+
const sslcrldir = nonEmpty(raw.query.get('sslcrldir'));
|
|
656
|
+
const sslkeylogfile = nonEmpty(raw.query.get('sslkeylogfile'));
|
|
657
|
+
// libpq sslsni / keepalives toggles (0/1) + keepalives_idle (seconds) +
|
|
658
|
+
// requirepeer (OS user, validated but not enforceable in Node).
|
|
659
|
+
const sslsni = parseLibpqBool(nonEmpty(raw.query.get('sslsni')));
|
|
660
|
+
const keepalives = parseLibpqBool(nonEmpty(raw.query.get('keepalives')));
|
|
661
|
+
const keepalivesIdle = parseKeepalivesIdle(nonEmpty(raw.query.get('keepalives_idle')));
|
|
662
|
+
const requirepeer = nonEmpty(raw.query.get('requirepeer'));
|
|
663
|
+
// libpq: `sslrootcert=system` raises the effective sslmode to verify-full.
|
|
664
|
+
// verify-full is the strongest mode, so this only ever raises it.
|
|
665
|
+
if (sslrootcert === 'system' && ssl !== 'verify-full') {
|
|
666
|
+
ssl = 'verify-full';
|
|
667
|
+
}
|
|
668
|
+
// libpq `hostaddr`: a fixed IP that bypasses DNS while `host` still drives
|
|
669
|
+
// TLS SNI / cert verification. Empty string is "not set".
|
|
670
|
+
const hostaddr = nonEmpty(raw.query.get('hostaddr'));
|
|
671
|
+
const sslMinProtocolVersion = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_min_protocol_version')), 'ssl_min_protocol_version');
|
|
672
|
+
const sslMaxProtocolVersion = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_max_protocol_version')), 'ssl_max_protocol_version');
|
|
673
|
+
assertTlsProtocolRange(sslMinProtocolVersion, sslMaxProtocolVersion);
|
|
674
|
+
assertTlsMaxProtocolSupported(sslMaxProtocolVersion);
|
|
675
|
+
// libpq rejects `sslnegotiation=direct` paired with a weak sslmode. The URI
|
|
676
|
+
// surface always resolves a concrete `ssl` (defaulting to 'prefer'), so the
|
|
677
|
+
// check is authoritative here.
|
|
678
|
+
assertSslNegotiationModeCompatible(ssl, sslnegotiation);
|
|
679
|
+
return {
|
|
680
|
+
host,
|
|
681
|
+
port,
|
|
682
|
+
user,
|
|
683
|
+
password,
|
|
684
|
+
database,
|
|
685
|
+
ssl,
|
|
686
|
+
channelBinding,
|
|
687
|
+
applicationName,
|
|
688
|
+
options,
|
|
689
|
+
...(sslnegotiation !== undefined ? { sslnegotiation } : {}),
|
|
690
|
+
...(hostaddr !== undefined ? { hostaddr } : {}),
|
|
691
|
+
...(replication !== undefined ? { replication } : {}),
|
|
692
|
+
...(hostsTuples.length > 1
|
|
693
|
+
? { hosts: hostsTuples.map((t) => ({ host: t.host, port: t.port })) }
|
|
694
|
+
: {}),
|
|
695
|
+
...(targetSessionAttrs !== undefined ? { targetSessionAttrs } : {}),
|
|
696
|
+
...(loadBalanceHosts !== undefined ? { loadBalanceHosts } : {}),
|
|
697
|
+
...(sslcert !== undefined ? { sslcert } : {}),
|
|
698
|
+
...(sslkey !== undefined ? { sslkey } : {}),
|
|
699
|
+
...(sslcertmode !== undefined ? { sslcertmode } : {}),
|
|
700
|
+
...(sslrootcert !== undefined ? { sslrootcert } : {}),
|
|
701
|
+
...(sslcrl !== undefined ? { sslcrl } : {}),
|
|
702
|
+
...(sslcrldir !== undefined ? { sslcrldir } : {}),
|
|
703
|
+
...(sslkeylogfile !== undefined ? { sslkeylogfile } : {}),
|
|
704
|
+
...(sslsni !== undefined ? { sslsni } : {}),
|
|
705
|
+
...(keepalives !== undefined ? { keepalives } : {}),
|
|
706
|
+
...(keepalivesIdle !== undefined ? { keepalivesIdle } : {}),
|
|
707
|
+
...(requirepeer !== undefined ? { requirepeer } : {}),
|
|
708
|
+
...(sslMinProtocolVersion !== undefined ? { sslMinProtocolVersion } : {}),
|
|
709
|
+
...(sslMaxProtocolVersion !== undefined ? { sslMaxProtocolVersion } : {}),
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
/**
|
|
713
|
+
* Parse a libpq 0/1 boolean connection parameter (`sslsni`, `keepalives`).
|
|
714
|
+
* libpq's `parse_bool_with_len` accepts `1`/`0`, `true`/`false`, `yes`/`no`,
|
|
715
|
+
* `on`/`off` (case-insensitive). Returns `undefined` for unset / empty /
|
|
716
|
+
* unrecognised so the caller falls back to libpq's default (enabled).
|
|
717
|
+
*/
|
|
718
|
+
const parseLibpqBool = (raw) => {
|
|
719
|
+
if (raw === undefined || raw === '')
|
|
720
|
+
return undefined;
|
|
721
|
+
switch (raw.toLowerCase()) {
|
|
722
|
+
case '1':
|
|
723
|
+
case 'true':
|
|
724
|
+
case 'yes':
|
|
725
|
+
case 'on':
|
|
726
|
+
return true;
|
|
727
|
+
case '0':
|
|
728
|
+
case 'false':
|
|
729
|
+
case 'no':
|
|
730
|
+
case 'off':
|
|
731
|
+
return false;
|
|
732
|
+
default:
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
/**
|
|
737
|
+
* Parse `keepalives_idle` (seconds, non-negative integer). Returns the value
|
|
738
|
+
* in seconds, or `undefined` if unset / malformed (the wire layer converts to
|
|
739
|
+
* milliseconds for `socket.setKeepAlive`'s `initialDelay`).
|
|
740
|
+
*/
|
|
741
|
+
const parseKeepalivesIdle = (raw) => {
|
|
742
|
+
if (raw === undefined || raw === '')
|
|
743
|
+
return undefined;
|
|
744
|
+
const n = Number.parseInt(raw, 10);
|
|
745
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
746
|
+
};
|
|
747
|
+
const nonEmpty = (v) => v === undefined || v === '' ? undefined : v;
|
|
748
|
+
/**
|
|
749
|
+
* Resolve the final {host, port}[] list for a URI:
|
|
750
|
+
*
|
|
751
|
+
* 1. Start from the authority. `?host=`/`?port=` query overrides take
|
|
752
|
+
* precedence (libpq semantics).
|
|
753
|
+
* 2. If `host=h1,h2,…` is supplied, parse the comma-list. Ports come from
|
|
754
|
+
* `port=p1,p2,…`; must match the host count or be a single value
|
|
755
|
+
* (broadcast to all hosts).
|
|
756
|
+
* 3. If only the authority had multi-host tuples (`postgresql://h1,h2/db`),
|
|
757
|
+
* use those.
|
|
758
|
+
* 4. Otherwise fall back to single-host.
|
|
759
|
+
*
|
|
760
|
+
* Validates every port is in 1..65535 and surfaces a clear error otherwise.
|
|
761
|
+
*/
|
|
762
|
+
const computeHostsTuples = (input) => {
|
|
763
|
+
const { rawHost, rawPort, rawAuthorityHosts, queryHost, queryPort } = input;
|
|
764
|
+
// Case A: ?host=… overrides the authority host(s). Port resolution still
|
|
765
|
+
// prefers `?port=` (if supplied), but falls back to the authority port so
|
|
766
|
+
// e.g. `postgres://:12345?host=/path/to/socket` keeps `port=12345`.
|
|
767
|
+
if (queryHost !== undefined && queryHost !== '') {
|
|
768
|
+
const hosts = queryHost.split(',').map((h) => h.trim());
|
|
769
|
+
const portList = queryPort !== undefined && queryPort !== ''
|
|
770
|
+
? queryPort.split(',').map((p) => p.trim())
|
|
771
|
+
: null;
|
|
772
|
+
if (portList !== null &&
|
|
773
|
+
portList.length !== 1 &&
|
|
774
|
+
portList.length !== hosts.length) {
|
|
775
|
+
throw new Error(`could not match ${String(portList.length)} port numbers to ${String(hosts.length)} hosts`);
|
|
776
|
+
}
|
|
777
|
+
return hosts.map((h, idx) => {
|
|
778
|
+
let portStr;
|
|
779
|
+
if (portList !== null) {
|
|
780
|
+
portStr = portList.length === 1 ? portList[0] : portList[idx];
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
// Fall back to the authority port. Multi-host without an explicit
|
|
784
|
+
// ?port= list shares the authority port across all hosts.
|
|
785
|
+
portStr = rawPort;
|
|
786
|
+
}
|
|
787
|
+
return { host: h, port: parsePort(portStr) };
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
// Case B: authority carried a comma-list (`postgresql://h1,h2:5433/db`).
|
|
791
|
+
// Query-string `?port=` can still broadcast or pair with this list.
|
|
792
|
+
if (rawAuthorityHosts !== undefined && rawAuthorityHosts.length > 0) {
|
|
793
|
+
const portList = queryPort !== undefined && queryPort !== ''
|
|
794
|
+
? queryPort.split(',').map((p) => p.trim())
|
|
795
|
+
: null;
|
|
796
|
+
if (portList !== null &&
|
|
797
|
+
portList.length !== 1 &&
|
|
798
|
+
portList.length !== rawAuthorityHosts.length) {
|
|
799
|
+
throw new Error(`could not match ${String(portList.length)} port numbers to ${String(rawAuthorityHosts.length)} hosts`);
|
|
800
|
+
}
|
|
801
|
+
return rawAuthorityHosts.map((t, idx) => ({
|
|
802
|
+
host: t.host,
|
|
803
|
+
port: parsePort(portList !== null
|
|
804
|
+
? portList.length === 1
|
|
805
|
+
? portList[0]
|
|
806
|
+
: portList[idx]
|
|
807
|
+
: t.port),
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
// Case C: single-host. Honour `?port=` (single value) if provided.
|
|
811
|
+
const portStr = queryPort ?? rawPort;
|
|
812
|
+
const host = rawHost !== undefined && rawHost !== '' ? rawHost : '';
|
|
813
|
+
return [{ host, port: parsePort(portStr) }];
|
|
814
|
+
};
|
|
815
|
+
const parsePort = (raw) => {
|
|
816
|
+
if (raw === undefined || raw === '')
|
|
817
|
+
return 5432;
|
|
818
|
+
// `Number.parseInt` silently tolerates trailing junk (`parseInt("12345 12")`
|
|
819
|
+
// === 12345), which would let an internal-whitespace value like the upstream
|
|
820
|
+
// `port = 12345 12` URI case sneak through as port 12345. libpq rejects that
|
|
821
|
+
// shape with `invalid integer value "<v>" for connection option "port"`,
|
|
822
|
+
// pointing at the whole bogus value (the whitespace included) rather than a
|
|
823
|
+
// generic out-of-range message. Detect any digits-then-garbage value here so
|
|
824
|
+
// that exact wording fires; genuinely non-numeric (`abc`) and out-of-range
|
|
825
|
+
// (`99999`) values keep the shorter `invalid port:` diagnostic.
|
|
826
|
+
if (/^\d/.test(raw) && !/^\d+$/.test(raw)) {
|
|
827
|
+
throw new Error(`invalid integer value "${raw}" for connection option "port"`);
|
|
828
|
+
}
|
|
829
|
+
const p = Number.parseInt(raw, 10);
|
|
830
|
+
if (!Number.isFinite(p) || p <= 0 || p > 65535) {
|
|
831
|
+
throw new Error(`invalid port: ${raw}`);
|
|
832
|
+
}
|
|
833
|
+
return p;
|
|
834
|
+
};
|
|
835
|
+
/**
|
|
836
|
+
* Parse a libpq-style conninfo string into a `Partial<ConnectOptions>`.
|
|
837
|
+
*
|
|
838
|
+
* The grammar is roughly `key = value` pairs separated by whitespace, where
|
|
839
|
+
* values may be quoted with single quotes (libpq's `\'` is honored as an
|
|
840
|
+
* embedded single-quote, but we don't model the full backslash-escape
|
|
841
|
+
* universe — there's no test corpus that exercises it). Unknown keys are
|
|
842
|
+
* rejected so a typo like `replicate=database` produces a clear error
|
|
843
|
+
* rather than silently dropping the parameter.
|
|
844
|
+
*
|
|
845
|
+
* Recognised keys mirror the URI-side `KNOWN_QUERY_KEYS` allowlist plus
|
|
846
|
+
* the authority-style keys (`host`, `port`, `user`, `dbname`, `password`).
|
|
847
|
+
* `replication` is normalised through `normalizeReplication` for libpq
|
|
848
|
+
* value-set compatibility.
|
|
849
|
+
*
|
|
850
|
+
* Out of scope: percent-decoding (conninfo strings are NOT percent-encoded),
|
|
851
|
+
* `service` resolution from `pg_service.conf`, `passfile` resolution.
|
|
852
|
+
*/
|
|
853
|
+
export const parseConninfo = (input) => {
|
|
854
|
+
const out = {};
|
|
855
|
+
let i = 0;
|
|
856
|
+
const n = input.length;
|
|
857
|
+
while (i < n) {
|
|
858
|
+
// Skip whitespace between pairs.
|
|
859
|
+
while (i < n && /\s/.test(input[i]))
|
|
860
|
+
i += 1;
|
|
861
|
+
if (i >= n)
|
|
862
|
+
break;
|
|
863
|
+
// Parse key: chars up to `=` or whitespace.
|
|
864
|
+
const keyStart = i;
|
|
865
|
+
while (i < n && input[i] !== '=' && !/\s/.test(input[i]))
|
|
866
|
+
i += 1;
|
|
867
|
+
const key = input.slice(keyStart, i).toLowerCase();
|
|
868
|
+
if (key === '')
|
|
869
|
+
break;
|
|
870
|
+
// Skip whitespace before `=`.
|
|
871
|
+
while (i < n && /\s/.test(input[i]))
|
|
872
|
+
i += 1;
|
|
873
|
+
if (i >= n || input[i] !== '=') {
|
|
874
|
+
throw new Error(`missing "=" after "${key}" in conninfo string`);
|
|
875
|
+
}
|
|
876
|
+
i += 1; // consume `=`
|
|
877
|
+
// Skip whitespace after `=`.
|
|
878
|
+
while (i < n && /\s/.test(input[i]))
|
|
879
|
+
i += 1;
|
|
880
|
+
// Parse value: either single-quoted (with `\'` and `\\` escapes) or
|
|
881
|
+
// bare up to next whitespace.
|
|
882
|
+
let value;
|
|
883
|
+
if (i < n && input[i] === "'") {
|
|
884
|
+
i += 1; // consume opening quote
|
|
885
|
+
const parts = [];
|
|
886
|
+
while (i < n && input[i] !== "'") {
|
|
887
|
+
if (input[i] === '\\' && i + 1 < n) {
|
|
888
|
+
parts.push(input[i + 1]);
|
|
889
|
+
i += 2;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
parts.push(input[i]);
|
|
893
|
+
i += 1;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (i >= n) {
|
|
897
|
+
throw new Error(`unterminated single quote in conninfo string for key "${key}"`);
|
|
898
|
+
}
|
|
899
|
+
i += 1; // consume closing quote
|
|
900
|
+
value = parts.join('');
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
const valStart = i;
|
|
904
|
+
while (i < n && !/\s/.test(input[i]))
|
|
905
|
+
i += 1;
|
|
906
|
+
value = input.slice(valStart, i);
|
|
907
|
+
}
|
|
908
|
+
applyConninfoPair(out, key, value);
|
|
909
|
+
}
|
|
910
|
+
// Materialise multi-host list. The scalar `host`/`port` already hold the
|
|
911
|
+
// first entry (so single-host callers see no surface change); we only
|
|
912
|
+
// surface `hosts` when the comma-list had ≥2 entries.
|
|
913
|
+
const hostList = out._hostList;
|
|
914
|
+
const portList = out._portList;
|
|
915
|
+
if (hostList !== undefined && hostList.length > 0) {
|
|
916
|
+
if (portList !== undefined &&
|
|
917
|
+
portList.length !== 1 &&
|
|
918
|
+
portList.length !== hostList.length) {
|
|
919
|
+
throw new Error(`could not match ${String(portList.length)} port numbers to ${String(hostList.length)} hosts`);
|
|
920
|
+
}
|
|
921
|
+
if (hostList.length > 1) {
|
|
922
|
+
out.hosts = hostList.map((h, idx) => ({
|
|
923
|
+
host: h,
|
|
924
|
+
port: portList === undefined
|
|
925
|
+
? (out.port ?? 5432)
|
|
926
|
+
: portList.length === 1
|
|
927
|
+
? parsePort(portList[0])
|
|
928
|
+
: parsePort(portList[idx]),
|
|
929
|
+
}));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// Drop the private staging fields before returning to the caller.
|
|
933
|
+
// `_service` is left in place — the layered connect resolver in
|
|
934
|
+
// `core/startup.ts` doesn't see this struct; only `runPsql` extracts
|
|
935
|
+
// and forwards the service name. The caller deletes the slot after
|
|
936
|
+
// reading it.
|
|
937
|
+
delete out._hostList;
|
|
938
|
+
delete out._portList;
|
|
939
|
+
assertTlsProtocolRange(out.sslMinProtocolVersion, out.sslMaxProtocolVersion);
|
|
940
|
+
assertTlsMaxProtocolSupported(out.sslMaxProtocolVersion);
|
|
941
|
+
return out;
|
|
942
|
+
};
|
|
943
|
+
const applyConninfoPair = (out, key, value) => {
|
|
944
|
+
switch (key) {
|
|
945
|
+
case 'host': {
|
|
946
|
+
// Multi-host: `host=h1,h2,h3`. Store the list aside; the post-pass
|
|
947
|
+
// (finalizeConninfo) materialises it into `hosts` + matches up against
|
|
948
|
+
// any `port=p1,p2,p3` list.
|
|
949
|
+
if (value.includes(',')) {
|
|
950
|
+
out._hostList = value.split(',').map((h) => h.trim());
|
|
951
|
+
out.host = out._hostList[0];
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
out.host = value;
|
|
955
|
+
out._hostList = undefined;
|
|
956
|
+
}
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
case 'port': {
|
|
960
|
+
if (value.includes(',')) {
|
|
961
|
+
out._portList = value.split(',').map((p) => p.trim());
|
|
962
|
+
// First port still goes into the scalar slot for back-compat.
|
|
963
|
+
out.port = parsePort(out._portList[0]);
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
out.port = parsePort(value);
|
|
967
|
+
out._portList = undefined;
|
|
968
|
+
}
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
case 'user':
|
|
972
|
+
out.user = value;
|
|
973
|
+
return;
|
|
974
|
+
case 'password':
|
|
975
|
+
out.password = value;
|
|
976
|
+
return;
|
|
977
|
+
case 'dbname':
|
|
978
|
+
out.database = value;
|
|
979
|
+
return;
|
|
980
|
+
case 'application_name':
|
|
981
|
+
out.applicationName = value;
|
|
982
|
+
return;
|
|
983
|
+
case 'sslmode':
|
|
984
|
+
out.ssl = normalizeSslMode(value);
|
|
985
|
+
return;
|
|
986
|
+
case 'channel_binding': {
|
|
987
|
+
const cb = normalizeChannelBinding(value);
|
|
988
|
+
if (cb !== undefined)
|
|
989
|
+
out.channelBinding = cb;
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
case 'require_auth': {
|
|
993
|
+
const ra = normalizeRequireAuth(value);
|
|
994
|
+
if (ra !== undefined)
|
|
995
|
+
out.requireAuth = ra;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
case 'connect_timeout': {
|
|
999
|
+
const t = Number.parseInt(value, 10);
|
|
1000
|
+
if (Number.isFinite(t) && t >= 0) {
|
|
1001
|
+
out.connectTimeoutMs = t * 1000;
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
case 'client_encoding':
|
|
1006
|
+
out.clientEncoding = value;
|
|
1007
|
+
return;
|
|
1008
|
+
case 'options':
|
|
1009
|
+
out.options = value;
|
|
1010
|
+
return;
|
|
1011
|
+
case 'replication': {
|
|
1012
|
+
const rep = normalizeReplication(value);
|
|
1013
|
+
if (rep !== undefined)
|
|
1014
|
+
out.replication = rep;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
case 'target_session_attrs': {
|
|
1018
|
+
const tsa = normalizeTargetSessionAttrs(value);
|
|
1019
|
+
if (tsa !== undefined)
|
|
1020
|
+
out.targetSessionAttrs = tsa;
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
case 'load_balance_hosts': {
|
|
1024
|
+
const lbh = normalizeLoadBalanceHosts(value);
|
|
1025
|
+
if (lbh !== undefined)
|
|
1026
|
+
out.loadBalanceHosts = lbh;
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
case 'gssencmode':
|
|
1030
|
+
// Unsupported (no GSSAPI); accept disable/prefer, reject require.
|
|
1031
|
+
validateGssEncMode(value);
|
|
1032
|
+
return;
|
|
1033
|
+
case 'sslcert':
|
|
1034
|
+
if (value !== '')
|
|
1035
|
+
out.sslcert = value;
|
|
1036
|
+
return;
|
|
1037
|
+
case 'sslkey':
|
|
1038
|
+
if (value !== '')
|
|
1039
|
+
out.sslkey = value;
|
|
1040
|
+
return;
|
|
1041
|
+
case 'sslcertmode': {
|
|
1042
|
+
const cm = normalizeSslCertMode(value);
|
|
1043
|
+
if (cm !== undefined)
|
|
1044
|
+
out.sslcertmode = cm;
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
case 'sslnegotiation': {
|
|
1048
|
+
const sn = normalizeSslNegotiation(value);
|
|
1049
|
+
if (sn !== undefined)
|
|
1050
|
+
out.sslnegotiation = sn;
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
case 'sslrootcert':
|
|
1054
|
+
if (value !== '')
|
|
1055
|
+
out.sslrootcert = value;
|
|
1056
|
+
return;
|
|
1057
|
+
case 'sslcrl':
|
|
1058
|
+
if (value !== '')
|
|
1059
|
+
out.sslcrl = value;
|
|
1060
|
+
return;
|
|
1061
|
+
case 'sslcrldir':
|
|
1062
|
+
if (value !== '')
|
|
1063
|
+
out.sslcrldir = value;
|
|
1064
|
+
return;
|
|
1065
|
+
case 'sslkeylogfile':
|
|
1066
|
+
if (value !== '')
|
|
1067
|
+
out.sslkeylogfile = value;
|
|
1068
|
+
return;
|
|
1069
|
+
case 'hostaddr':
|
|
1070
|
+
if (value !== '')
|
|
1071
|
+
out.hostaddr = value;
|
|
1072
|
+
return;
|
|
1073
|
+
case 'ssl_min_protocol_version': {
|
|
1074
|
+
const v = normalizeTlsProtocolVersion(value === '' ? undefined : value, 'ssl_min_protocol_version');
|
|
1075
|
+
if (v !== undefined)
|
|
1076
|
+
out.sslMinProtocolVersion = v;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
case 'ssl_max_protocol_version': {
|
|
1080
|
+
const v = normalizeTlsProtocolVersion(value === '' ? undefined : value, 'ssl_max_protocol_version');
|
|
1081
|
+
if (v !== undefined)
|
|
1082
|
+
out.sslMaxProtocolVersion = v;
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
case 'sslsni': {
|
|
1086
|
+
const b = parseLibpqBool(value);
|
|
1087
|
+
if (b !== undefined)
|
|
1088
|
+
out.sslsni = b;
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
case 'keepalives': {
|
|
1092
|
+
const b = parseLibpqBool(value);
|
|
1093
|
+
if (b !== undefined)
|
|
1094
|
+
out.keepalives = b;
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
case 'keepalives_idle': {
|
|
1098
|
+
const n = parseKeepalivesIdle(value);
|
|
1099
|
+
if (n !== undefined)
|
|
1100
|
+
out.keepalivesIdle = n;
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
case 'requirepeer':
|
|
1104
|
+
if (value !== '')
|
|
1105
|
+
out.requirepeer = value;
|
|
1106
|
+
return;
|
|
1107
|
+
// Recognised libpq keys that we don't model — accept silently so we
|
|
1108
|
+
// don't reject legitimate connection strings. keepalives_interval /
|
|
1109
|
+
// keepalives_count have no Node net API equivalent (setKeepAlive only
|
|
1110
|
+
// exposes enable + initial delay) — recognised but cannot be applied.
|
|
1111
|
+
case 'passfile':
|
|
1112
|
+
case 'sslcompression':
|
|
1113
|
+
case 'krbsrvname':
|
|
1114
|
+
case 'gsslib':
|
|
1115
|
+
case 'fallback_application_name':
|
|
1116
|
+
case 'keepalives_interval':
|
|
1117
|
+
case 'keepalives_count':
|
|
1118
|
+
return;
|
|
1119
|
+
case 'service': {
|
|
1120
|
+
// Service name is NOT a ConnectOptions field — it's resolved by
|
|
1121
|
+
// the layered connect resolver in `core/startup.ts`. Stash it on
|
|
1122
|
+
// a private staging slot so the caller (`runPsql`) can extract it
|
|
1123
|
+
// alongside the URI-side `?service=…` parser.
|
|
1124
|
+
out._service = value;
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
default:
|
|
1128
|
+
throw new Error(`invalid conninfo key: "${key}"`);
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
/**
|
|
1132
|
+
* Heuristic: does the `-d` value look like a connection URI or a conninfo
|
|
1133
|
+
* string (vs. a bare database name)? Mirrors libpq's
|
|
1134
|
+
* `recognized_connection_string()` test.
|
|
1135
|
+
*/
|
|
1136
|
+
export const looksLikeConnectionString = (s) => {
|
|
1137
|
+
if (s.startsWith('postgresql://') || s.startsWith('postgres://'))
|
|
1138
|
+
return true;
|
|
1139
|
+
// A bare key=value pair (or several) — conninfo. We require the `=` to
|
|
1140
|
+
// appear before any whitespace so values like "weird name" (a bareword
|
|
1141
|
+
// database name with a space) don't get misclassified.
|
|
1142
|
+
const eq = s.indexOf('=');
|
|
1143
|
+
if (eq < 0)
|
|
1144
|
+
return false;
|
|
1145
|
+
const head = s.slice(0, eq);
|
|
1146
|
+
return !/\s/.test(head);
|
|
1147
|
+
};
|
|
1148
|
+
const normalizeSslMode = (raw) => {
|
|
1149
|
+
// Only null/unset defaults to 'prefer'. An explicit but unrecognized value
|
|
1150
|
+
// (e.g. a `verify-ful` typo) MUST be rejected — silently falling back to
|
|
1151
|
+
// 'prefer' would proceed with no cert-chain / hostname verification, a TLS
|
|
1152
|
+
// downgrade. Mirrors libpq's `invalid sslmode value: "..."` and every
|
|
1153
|
+
// sibling validator (normalizeChannelBinding, normalizeSslCertMode, …).
|
|
1154
|
+
if (raw === null)
|
|
1155
|
+
return 'prefer';
|
|
1156
|
+
const value = raw.toLowerCase();
|
|
1157
|
+
switch (value) {
|
|
1158
|
+
case 'disable':
|
|
1159
|
+
case 'allow':
|
|
1160
|
+
case 'prefer':
|
|
1161
|
+
case 'require':
|
|
1162
|
+
case 'verify-ca':
|
|
1163
|
+
case 'verify-full':
|
|
1164
|
+
return value;
|
|
1165
|
+
default:
|
|
1166
|
+
throw new Error(`invalid sslmode value: "${raw}"`);
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
/**
|
|
1170
|
+
* libpq's accepted TLS protocol-version names, in ascending order. The
|
|
1171
|
+
* index doubles as the comparison key for the `min > max` check. Matching
|
|
1172
|
+
* is case-insensitive on input (libpq lowercases before comparing) but we
|
|
1173
|
+
* keep the canonical mixed-case spelling Node's `tls` module expects for
|
|
1174
|
+
* `minVersion` / `maxVersion`.
|
|
1175
|
+
*/
|
|
1176
|
+
const TLS_PROTOCOL_VERSIONS = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'];
|
|
1177
|
+
/**
|
|
1178
|
+
* Validate and canonicalise a `ssl_{min,max}_protocol_version` value. Returns
|
|
1179
|
+
* the canonical spelling (`TLSv1.2` etc.) or `undefined` for empty / unset.
|
|
1180
|
+
* Throws libpq's `invalid <key> value: "<raw>"` wording on a malformed value.
|
|
1181
|
+
*/
|
|
1182
|
+
const normalizeTlsProtocolVersion = (raw, key) => {
|
|
1183
|
+
if (raw === undefined || raw === '')
|
|
1184
|
+
return undefined;
|
|
1185
|
+
const match = TLS_PROTOCOL_VERSIONS.find((v) => v.toLowerCase() === raw.toLowerCase());
|
|
1186
|
+
if (match === undefined) {
|
|
1187
|
+
throw new Error(`invalid ${key} value: "${raw}"`);
|
|
1188
|
+
}
|
|
1189
|
+
return match;
|
|
1190
|
+
};
|
|
1191
|
+
/**
|
|
1192
|
+
* Reject a `ssl_max_protocol_version` ceiling below TLSv1.2. TLS 1.0/1.1 are
|
|
1193
|
+
* disabled in Node's bundled OpenSSL, so capping the ceiling there leaves no
|
|
1194
|
+
* negotiable protocol and the handshake otherwise fails with an opaque
|
|
1195
|
+
* `ERR_SSL_NO_PROTOCOLS_AVAILABLE` — surface an actionable message at parse
|
|
1196
|
+
* time instead. Only the MAX is gated: a low *min*
|
|
1197
|
+
* (`ssl_min_protocol_version=TLSv1.1`) is harmless because Node still
|
|
1198
|
+
* negotiates the highest mutually-supported version (1.2/1.3), exactly as
|
|
1199
|
+
* libpq does on a modern OpenSSL. Called AFTER {@link assertTlsProtocolRange}
|
|
1200
|
+
* so an inverted range (min > max) reports the range error first, matching
|
|
1201
|
+
* libpq's ordering.
|
|
1202
|
+
*/
|
|
1203
|
+
const assertTlsMaxProtocolSupported = (max) => {
|
|
1204
|
+
if (max === 'TLSv1' || max === 'TLSv1.1') {
|
|
1205
|
+
throw new Error(`ssl_max_protocol_version "${max}" is not supported by this ` +
|
|
1206
|
+
`runtime's TLS library — TLS 1.0/1.1 are disabled in Node's OpenSSL; ` +
|
|
1207
|
+
`the minimum negotiable version is TLSv1.2`);
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
/**
|
|
1211
|
+
* Reject a `ssl_min_protocol_version` that is higher than
|
|
1212
|
+
* `ssl_max_protocol_version`, matching libpq's
|
|
1213
|
+
* `ssl_min_protocol_version must be <= ssl_max_protocol_version` diagnostic.
|
|
1214
|
+
* Both arguments must already be canonicalised by
|
|
1215
|
+
* {@link normalizeTlsProtocolVersion}.
|
|
1216
|
+
*/
|
|
1217
|
+
const assertTlsProtocolRange = (min, max) => {
|
|
1218
|
+
if (min === undefined || max === undefined)
|
|
1219
|
+
return;
|
|
1220
|
+
if (TLS_PROTOCOL_VERSIONS.indexOf(min) > TLS_PROTOCOL_VERSIONS.indexOf(max)) {
|
|
1221
|
+
throw new Error(`ssl_min_protocol_version must be <= ssl_max_protocol_version`);
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
const normalizeChannelBinding = (raw) => {
|
|
1225
|
+
if (raw === null || raw === '')
|
|
1226
|
+
return undefined;
|
|
1227
|
+
const value = raw.toLowerCase();
|
|
1228
|
+
switch (value) {
|
|
1229
|
+
case 'disable':
|
|
1230
|
+
case 'prefer':
|
|
1231
|
+
case 'require':
|
|
1232
|
+
return value;
|
|
1233
|
+
default:
|
|
1234
|
+
// Mirror libpq's `invalid channel_binding value: "<raw>"`
|
|
1235
|
+
// diagnostic (upstream test `002_scram.pl`). Empty / unset
|
|
1236
|
+
// returns `undefined` above so the wire-layer default applies.
|
|
1237
|
+
throw new Error(`invalid channel_binding value: "${raw}"`);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
/**
|
|
1241
|
+
* Validate libpq's `gssencmode` (GSSAPI transport encryption).
|
|
1242
|
+
*
|
|
1243
|
+
* This client has NO GSSAPI support: GSS-API `gss_wrap`/`gss_unwrap` would
|
|
1244
|
+
* require a native Kerberos addon (e.g. the `kerberos` npm), which the
|
|
1245
|
+
* embedded psql deliberately avoids (pure-TS, zero native bindings — the
|
|
1246
|
+
* same reason the line editor is hand-rolled). `node-postgres` doesn't
|
|
1247
|
+
* support it either. So:
|
|
1248
|
+
* - `disable` / `prefer` — accepted and ignored: neither needs GSS
|
|
1249
|
+
* (`prefer` means "try GSS, else fall back", and falling back to the
|
|
1250
|
+
* non-GSS path is exactly what we always do).
|
|
1251
|
+
* - `require` — rejected with a clear diagnostic; we cannot satisfy it.
|
|
1252
|
+
* - anything else — `invalid gssencmode value`.
|
|
1253
|
+
* We recognise the parameter (rather than rejecting it as an unknown key)
|
|
1254
|
+
* so the many tools that always append `gssencmode=...` to a URI keep
|
|
1255
|
+
* working against Neon.
|
|
1256
|
+
*/
|
|
1257
|
+
const validateGssEncMode = (raw) => {
|
|
1258
|
+
if (raw === null || raw === '')
|
|
1259
|
+
return;
|
|
1260
|
+
const value = raw.toLowerCase();
|
|
1261
|
+
if (value === 'disable' || value === 'prefer')
|
|
1262
|
+
return;
|
|
1263
|
+
if (value === 'require') {
|
|
1264
|
+
throw new Error('gssencmode=require is not supported: this client has no GSSAPI support');
|
|
1265
|
+
}
|
|
1266
|
+
throw new Error(`invalid gssencmode value: "${raw}"`);
|
|
1267
|
+
};
|
|
1268
|
+
/**
|
|
1269
|
+
* Parse libpq's `sslcertmode` value (`disable` / `allow` / `require`).
|
|
1270
|
+
* Empty / unset returns `undefined` so the wire-layer default (`allow`)
|
|
1271
|
+
* applies. A malformed value throws libpq's
|
|
1272
|
+
* `invalid sslcertmode value: "<raw>"` diagnostic.
|
|
1273
|
+
*/
|
|
1274
|
+
const normalizeSslCertMode = (raw) => {
|
|
1275
|
+
if (raw === null || raw === '')
|
|
1276
|
+
return undefined;
|
|
1277
|
+
const value = raw.toLowerCase();
|
|
1278
|
+
switch (value) {
|
|
1279
|
+
case 'disable':
|
|
1280
|
+
case 'allow':
|
|
1281
|
+
case 'require':
|
|
1282
|
+
return value;
|
|
1283
|
+
default:
|
|
1284
|
+
throw new Error(`invalid sslcertmode value: "${raw}"`);
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
/**
|
|
1288
|
+
* Parse libpq's `sslnegotiation` value (`postgres` / `direct`). Empty / unset
|
|
1289
|
+
* returns `undefined` so the wire-layer default (`postgres`, the classic
|
|
1290
|
+
* SSLRequest flow) applies. A malformed value throws libpq's
|
|
1291
|
+
* `invalid sslnegotiation value: "<raw>"` diagnostic.
|
|
1292
|
+
*/
|
|
1293
|
+
const normalizeSslNegotiation = (raw) => {
|
|
1294
|
+
if (raw === null || raw === '')
|
|
1295
|
+
return undefined;
|
|
1296
|
+
const value = raw.toLowerCase();
|
|
1297
|
+
switch (value) {
|
|
1298
|
+
case 'postgres':
|
|
1299
|
+
case 'direct':
|
|
1300
|
+
return value;
|
|
1301
|
+
default:
|
|
1302
|
+
throw new Error(`invalid sslnegotiation value: "${raw}"`);
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
/**
|
|
1306
|
+
* libpq constraint: `sslnegotiation=direct` may only be used with an encrypted
|
|
1307
|
+
* sslmode (`require` / `verify-ca` / `verify-full`). Direct SSL starts the TLS
|
|
1308
|
+
* handshake immediately with no plaintext fallback, so a "weak" mode that could
|
|
1309
|
+
* end up unencrypted (`disable` / `allow` / `prefer`) is rejected with libpq's
|
|
1310
|
+
* exact `pqConnectOptions2` wording. No-op unless `sslnegotiation` is `direct`.
|
|
1311
|
+
*/
|
|
1312
|
+
const assertSslNegotiationModeCompatible = (ssl, sslnegotiation) => {
|
|
1313
|
+
if (sslnegotiation !== 'direct')
|
|
1314
|
+
return;
|
|
1315
|
+
if (ssl === 'require' || ssl === 'verify-ca' || ssl === 'verify-full') {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
throw new Error(`weak sslmode "${ssl}" may not be used with sslnegotiation=direct`);
|
|
1319
|
+
};
|
|
1320
|
+
const VALID_REQUIRE_AUTH_METHODS = new Set([
|
|
1321
|
+
'password',
|
|
1322
|
+
'md5',
|
|
1323
|
+
'gss',
|
|
1324
|
+
'sspi',
|
|
1325
|
+
'scram-sha-256',
|
|
1326
|
+
'creds',
|
|
1327
|
+
'none',
|
|
1328
|
+
]);
|
|
1329
|
+
/**
|
|
1330
|
+
* Parse libpq's `require_auth` value: a comma-separated list of method
|
|
1331
|
+
* names where each entry may be prefixed with `!` to negate. Mixing
|
|
1332
|
+
* positive and negative entries is forbidden (libpq matches this).
|
|
1333
|
+
*
|
|
1334
|
+
* Returns `undefined` for empty input so the wire-layer default applies.
|
|
1335
|
+
* Throws on invalid syntax with libpq-parity wording, surfaced via the
|
|
1336
|
+
* outer `psql: error: ...` channel.
|
|
1337
|
+
*/
|
|
1338
|
+
const normalizeRequireAuth = (raw) => {
|
|
1339
|
+
if (raw === null || raw === '')
|
|
1340
|
+
return undefined;
|
|
1341
|
+
const tokens = raw
|
|
1342
|
+
.split(',')
|
|
1343
|
+
.map((s) => s.trim())
|
|
1344
|
+
.filter((s) => s.length > 0);
|
|
1345
|
+
if (tokens.length === 0)
|
|
1346
|
+
return undefined;
|
|
1347
|
+
const methods = new Set();
|
|
1348
|
+
let polarity = null;
|
|
1349
|
+
for (const token of tokens) {
|
|
1350
|
+
const isNeg = token.startsWith('!');
|
|
1351
|
+
const name = (isNeg ? token.slice(1) : token).toLowerCase();
|
|
1352
|
+
if (!VALID_REQUIRE_AUTH_METHODS.has(name)) {
|
|
1353
|
+
throw new Error(`invalid require_auth method: "${token}"`);
|
|
1354
|
+
}
|
|
1355
|
+
if (polarity === null) {
|
|
1356
|
+
polarity = isNeg;
|
|
1357
|
+
}
|
|
1358
|
+
else if (polarity !== isNeg) {
|
|
1359
|
+
// libpq wording: "negative require_auth method ... cannot be mixed
|
|
1360
|
+
// with non-negative methods". We use a slightly shorter form here.
|
|
1361
|
+
throw new Error('require_auth methods cannot mix positive and negative entries');
|
|
1362
|
+
}
|
|
1363
|
+
methods.add(name);
|
|
1364
|
+
}
|
|
1365
|
+
return { methods, negated: polarity ?? false };
|
|
1366
|
+
};
|
|
1367
|
+
/**
|
|
1368
|
+
* Accept the libpq-spec set for `target_session_attrs`. Aliases `read-write`/
|
|
1369
|
+
* `primary` and `read-only`/`standby` are kept distinct because the wire
|
|
1370
|
+
* layer treats the canonical four values identically — we only normalise
|
|
1371
|
+
* unknown / empty inputs to `undefined` so the wire-layer default ('any')
|
|
1372
|
+
* applies. Throws on unrecognised values, matching libpq behaviour.
|
|
1373
|
+
*/
|
|
1374
|
+
const normalizeTargetSessionAttrs = (raw) => {
|
|
1375
|
+
if (raw === null || raw === '')
|
|
1376
|
+
return undefined;
|
|
1377
|
+
const value = raw.toLowerCase();
|
|
1378
|
+
switch (value) {
|
|
1379
|
+
case 'any':
|
|
1380
|
+
case 'read-write':
|
|
1381
|
+
case 'read-only':
|
|
1382
|
+
case 'primary':
|
|
1383
|
+
case 'standby':
|
|
1384
|
+
case 'prefer-standby':
|
|
1385
|
+
return value;
|
|
1386
|
+
default:
|
|
1387
|
+
throw new Error(`invalid value for "target_session_attrs": "${raw}"`);
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
/**
|
|
1391
|
+
* Accept the libpq-spec set for `load_balance_hosts`:
|
|
1392
|
+
* - `disable` (default) — preserve list order
|
|
1393
|
+
* - `random` — shuffle before iteration
|
|
1394
|
+
*
|
|
1395
|
+
* Unknown values throw; empty / unset returns `undefined` so the wire layer
|
|
1396
|
+
* default ('disable') applies.
|
|
1397
|
+
*/
|
|
1398
|
+
const normalizeLoadBalanceHosts = (raw) => {
|
|
1399
|
+
if (raw === null || raw === '')
|
|
1400
|
+
return undefined;
|
|
1401
|
+
const value = raw.toLowerCase();
|
|
1402
|
+
if (value === 'disable' || value === 'random')
|
|
1403
|
+
return value;
|
|
1404
|
+
throw new Error(`invalid value for "load_balance_hosts": "${raw}"`);
|
|
1405
|
+
};
|
|
1406
|
+
/**
|
|
1407
|
+
* libpq accepts a wide set of "truthy" values for `replication`:
|
|
1408
|
+
* - `true` / `on` / `yes` / `1` → physical replication (mapped to `true`)
|
|
1409
|
+
* - `false` / `off` / `no` / `0` → not a walsender (no replication mode)
|
|
1410
|
+
* - `database` → logical replication on that DB
|
|
1411
|
+
*
|
|
1412
|
+
* Returns `undefined` when no value was supplied or when it explicitly
|
|
1413
|
+
* disables replication; throws for unrecognised input (matching libpq's
|
|
1414
|
+
* `"invalid <…> value"` semantics so users see a clear error rather than
|
|
1415
|
+
* silently sending an unexpected startup-message parameter).
|
|
1416
|
+
*/
|
|
1417
|
+
const normalizeReplication = (raw) => {
|
|
1418
|
+
if (raw === null || raw === '')
|
|
1419
|
+
return undefined;
|
|
1420
|
+
const value = raw.toLowerCase();
|
|
1421
|
+
if (value === 'database')
|
|
1422
|
+
return 'database';
|
|
1423
|
+
if (value === 'true' || value === 'on' || value === 'yes' || value === '1') {
|
|
1424
|
+
return 'true';
|
|
1425
|
+
}
|
|
1426
|
+
if (value === 'false' || value === 'off' || value === 'no' || value === '0') {
|
|
1427
|
+
return undefined;
|
|
1428
|
+
}
|
|
1429
|
+
throw new Error(`invalid value for "replication": "${raw}"`);
|
|
1430
|
+
};
|
|
1431
|
+
// ---------------------------------------------------------------------------
|
|
1432
|
+
// Layered connection-parameter resolution.
|
|
1433
|
+
//
|
|
1434
|
+
// Vanilla psql consults several sources in priority order when filling in
|
|
1435
|
+
// connection parameters. Order (highest → lowest):
|
|
1436
|
+
//
|
|
1437
|
+
// 1. Explicit URI / conninfo (what the user passed on the command line)
|
|
1438
|
+
// 2. Argv flags (`-h`, `-p`, etc.)
|
|
1439
|
+
// 3. PG* env vars
|
|
1440
|
+
// 4. ~/.pgpass (password only; matched against host/port/db/user)
|
|
1441
|
+
// 5. pg_service.conf (when PGSERVICE / ?service= is set)
|
|
1442
|
+
// 6. libpq compiled-in defaults (localhost / 5432 / USER / database=user)
|
|
1443
|
+
//
|
|
1444
|
+
// The historical `parseConnectionUri` bakes (1) and (6) into a single
|
|
1445
|
+
// `ConnectOptions`, which makes layering impossible. `parseConnectionUriPartial`
|
|
1446
|
+
// gives the same parser surface but returns ONLY the fields the URI
|
|
1447
|
+
// explicitly set — leaving env / pgpass / service / defaults to fill the
|
|
1448
|
+
// gaps via `mergeConnectOptions`.
|
|
1449
|
+
// ---------------------------------------------------------------------------
|
|
1450
|
+
/**
|
|
1451
|
+
* Service name extracted from a connection URI's `?service=` query
|
|
1452
|
+
* parameter, if any. Surfaced alongside `parseConnectionUriPartial` so the
|
|
1453
|
+
* caller can route the lookup into `applyStartupArgs` without re-parsing
|
|
1454
|
+
* the URI.
|
|
1455
|
+
*/
|
|
1456
|
+
export const parseConnectionUriService = (uri) => {
|
|
1457
|
+
const raw = tokenizeConnectionUri(uri);
|
|
1458
|
+
const value = raw.query.get('service');
|
|
1459
|
+
return value === undefined || value === '' ? undefined : value;
|
|
1460
|
+
};
|
|
1461
|
+
/**
|
|
1462
|
+
* Parse a URI into a `Partial<ConnectOptions>` containing only the fields
|
|
1463
|
+
* the URI explicitly supplied. Returned shape:
|
|
1464
|
+
*
|
|
1465
|
+
* - missing fields are absent (no `undefined` placeholders)
|
|
1466
|
+
* - `host`/`port` are populated only when the URI authority or `?host=`
|
|
1467
|
+
* specified them
|
|
1468
|
+
* - `user`/`password`/`database`/`ssl`/`channelBinding`/... follow the
|
|
1469
|
+
* same rule — present iff explicitly set
|
|
1470
|
+
*
|
|
1471
|
+
* This is the building block for the layered merge in `applyStartupArgs`.
|
|
1472
|
+
* The full-defaults variant `parseConnectionUri` remains the right choice
|
|
1473
|
+
* for callers that want a complete `ConnectOptions` (e.g. the existing
|
|
1474
|
+
* `-d URI` path); it's kept untouched for back-compat.
|
|
1475
|
+
*/
|
|
1476
|
+
export const parseConnectionUriPartial = (uri) => {
|
|
1477
|
+
const raw = tokenizeConnectionUri(uri);
|
|
1478
|
+
const queryUser = raw.query.get('user');
|
|
1479
|
+
const queryPassword = raw.query.get('password');
|
|
1480
|
+
const queryPort = raw.query.get('port');
|
|
1481
|
+
const queryDbname = raw.query.get('dbname');
|
|
1482
|
+
const queryHost = raw.query.get('host');
|
|
1483
|
+
// Multi-host: same resolution as the full parser, but we treat its absence
|
|
1484
|
+
// as "URI didn't say anything about hosts" rather than synthesising a
|
|
1485
|
+
// localhost default.
|
|
1486
|
+
const hostsTuples = computeHostsTuples({
|
|
1487
|
+
rawHost: raw.host,
|
|
1488
|
+
rawPort: raw.port,
|
|
1489
|
+
rawAuthorityHosts: raw.hosts,
|
|
1490
|
+
queryHost,
|
|
1491
|
+
queryPort,
|
|
1492
|
+
});
|
|
1493
|
+
// Did the URI actually mention a port anywhere? `parsePort()` defaults
|
|
1494
|
+
// empty input to 5432, so `hostsTuples[i].port` is ALWAYS a number even
|
|
1495
|
+
// for a URI like `postgres:///?service=foo` (no port specified). We
|
|
1496
|
+
// need to distinguish "URI explicitly said 5432" from "URI said nothing
|
|
1497
|
+
// about a port" so the service-file's port wins when we layer this
|
|
1498
|
+
// partial above the service layer. Mirrors libpq's behaviour for
|
|
1499
|
+
// `006_service.pl`'s `postgres:///?service=…` cases.
|
|
1500
|
+
const portInUri = (raw.port !== undefined && raw.port !== '') ||
|
|
1501
|
+
(queryPort !== undefined && queryPort !== '') ||
|
|
1502
|
+
(raw.hosts?.some((t) => t.port !== undefined && t.port !== '') ?? false);
|
|
1503
|
+
const out = {};
|
|
1504
|
+
if (hostsTuples.length > 0) {
|
|
1505
|
+
// First tuple drives the single-host surface; the full multi-host list
|
|
1506
|
+
// is included only when the URI specified more than one. An empty-host
|
|
1507
|
+
// tuple (e.g. `postgres://:12345/`) means "explicit port, no host" —
|
|
1508
|
+
// we record the port but leave host to the next layer.
|
|
1509
|
+
if (hostsTuples[0].host !== '')
|
|
1510
|
+
out.host = hostsTuples[0].host;
|
|
1511
|
+
if (portInUri && hostsTuples[0].port !== 0)
|
|
1512
|
+
out.port = hostsTuples[0].port;
|
|
1513
|
+
if (hostsTuples.length > 1) {
|
|
1514
|
+
out.hosts = hostsTuples.map((t) => ({ host: t.host, port: t.port }));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const userExplicit = queryUser !== undefined && queryUser !== ''
|
|
1518
|
+
? queryUser
|
|
1519
|
+
: raw.user !== undefined && raw.user !== ''
|
|
1520
|
+
? raw.user
|
|
1521
|
+
: undefined;
|
|
1522
|
+
if (userExplicit !== undefined)
|
|
1523
|
+
out.user = userExplicit;
|
|
1524
|
+
const password = queryPassword ?? raw.password;
|
|
1525
|
+
if (password !== undefined)
|
|
1526
|
+
out.password = password;
|
|
1527
|
+
const database = queryDbname ?? raw.database;
|
|
1528
|
+
if (database !== undefined)
|
|
1529
|
+
out.database = database;
|
|
1530
|
+
const sslRaw = raw.query.get('sslmode');
|
|
1531
|
+
if (sslRaw !== undefined && sslRaw !== '') {
|
|
1532
|
+
out.ssl = normalizeSslMode(sslRaw);
|
|
1533
|
+
}
|
|
1534
|
+
const cb = normalizeChannelBinding(raw.query.get('channel_binding') ?? null);
|
|
1535
|
+
if (cb !== undefined)
|
|
1536
|
+
out.channelBinding = cb;
|
|
1537
|
+
const ra = normalizeRequireAuth(raw.query.get('require_auth') ?? null);
|
|
1538
|
+
if (ra !== undefined)
|
|
1539
|
+
out.requireAuth = ra;
|
|
1540
|
+
const options = raw.query.get('options');
|
|
1541
|
+
if (options !== undefined && options !== '')
|
|
1542
|
+
out.options = options;
|
|
1543
|
+
const appName = raw.query.get('application_name');
|
|
1544
|
+
if (appName !== undefined && appName !== '')
|
|
1545
|
+
out.applicationName = appName;
|
|
1546
|
+
const replication = normalizeReplication(raw.query.get('replication') ?? null);
|
|
1547
|
+
if (replication !== undefined)
|
|
1548
|
+
out.replication = replication;
|
|
1549
|
+
const targetSessionAttrs = normalizeTargetSessionAttrs(raw.query.get('target_session_attrs') ?? null);
|
|
1550
|
+
if (targetSessionAttrs !== undefined) {
|
|
1551
|
+
out.targetSessionAttrs = targetSessionAttrs;
|
|
1552
|
+
}
|
|
1553
|
+
const loadBalanceHosts = normalizeLoadBalanceHosts(raw.query.get('load_balance_hosts') ?? null);
|
|
1554
|
+
if (loadBalanceHosts !== undefined)
|
|
1555
|
+
out.loadBalanceHosts = loadBalanceHosts;
|
|
1556
|
+
const sslcert = nonEmpty(raw.query.get('sslcert'));
|
|
1557
|
+
if (sslcert !== undefined)
|
|
1558
|
+
out.sslcert = sslcert;
|
|
1559
|
+
const sslkey = nonEmpty(raw.query.get('sslkey'));
|
|
1560
|
+
if (sslkey !== undefined)
|
|
1561
|
+
out.sslkey = sslkey;
|
|
1562
|
+
const sslcertmode = normalizeSslCertMode(raw.query.get('sslcertmode') ?? null);
|
|
1563
|
+
if (sslcertmode !== undefined)
|
|
1564
|
+
out.sslcertmode = sslcertmode;
|
|
1565
|
+
const sslnegotiation = normalizeSslNegotiation(raw.query.get('sslnegotiation') ?? null);
|
|
1566
|
+
if (sslnegotiation !== undefined)
|
|
1567
|
+
out.sslnegotiation = sslnegotiation;
|
|
1568
|
+
const sslrootcert = nonEmpty(raw.query.get('sslrootcert'));
|
|
1569
|
+
if (sslrootcert !== undefined)
|
|
1570
|
+
out.sslrootcert = sslrootcert;
|
|
1571
|
+
const sslcrl = nonEmpty(raw.query.get('sslcrl'));
|
|
1572
|
+
if (sslcrl !== undefined)
|
|
1573
|
+
out.sslcrl = sslcrl;
|
|
1574
|
+
const sslcrldir = nonEmpty(raw.query.get('sslcrldir'));
|
|
1575
|
+
if (sslcrldir !== undefined)
|
|
1576
|
+
out.sslcrldir = sslcrldir;
|
|
1577
|
+
const sslkeylogfile = nonEmpty(raw.query.get('sslkeylogfile'));
|
|
1578
|
+
if (sslkeylogfile !== undefined)
|
|
1579
|
+
out.sslkeylogfile = sslkeylogfile;
|
|
1580
|
+
const sslsni = parseLibpqBool(nonEmpty(raw.query.get('sslsni')));
|
|
1581
|
+
if (sslsni !== undefined)
|
|
1582
|
+
out.sslsni = sslsni;
|
|
1583
|
+
const keepalives = parseLibpqBool(nonEmpty(raw.query.get('keepalives')));
|
|
1584
|
+
if (keepalives !== undefined)
|
|
1585
|
+
out.keepalives = keepalives;
|
|
1586
|
+
const keepalivesIdle = parseKeepalivesIdle(nonEmpty(raw.query.get('keepalives_idle')));
|
|
1587
|
+
if (keepalivesIdle !== undefined)
|
|
1588
|
+
out.keepalivesIdle = keepalivesIdle;
|
|
1589
|
+
const requirepeer = nonEmpty(raw.query.get('requirepeer'));
|
|
1590
|
+
if (requirepeer !== undefined)
|
|
1591
|
+
out.requirepeer = requirepeer;
|
|
1592
|
+
const hostaddr = nonEmpty(raw.query.get('hostaddr'));
|
|
1593
|
+
if (hostaddr !== undefined)
|
|
1594
|
+
out.hostaddr = hostaddr;
|
|
1595
|
+
const sslMin = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_min_protocol_version')), 'ssl_min_protocol_version');
|
|
1596
|
+
if (sslMin !== undefined)
|
|
1597
|
+
out.sslMinProtocolVersion = sslMin;
|
|
1598
|
+
const sslMax = normalizeTlsProtocolVersion(nonEmpty(raw.query.get('ssl_max_protocol_version')), 'ssl_max_protocol_version');
|
|
1599
|
+
if (sslMax !== undefined)
|
|
1600
|
+
out.sslMaxProtocolVersion = sslMax;
|
|
1601
|
+
assertTlsProtocolRange(out.sslMinProtocolVersion, out.sslMaxProtocolVersion);
|
|
1602
|
+
assertTlsMaxProtocolSupported(out.sslMaxProtocolVersion);
|
|
1603
|
+
const connectTimeoutSec = raw.query.get('connect_timeout');
|
|
1604
|
+
if (connectTimeoutSec !== undefined && connectTimeoutSec !== '') {
|
|
1605
|
+
const t = Number.parseInt(connectTimeoutSec, 10);
|
|
1606
|
+
if (Number.isFinite(t) && t >= 0)
|
|
1607
|
+
out.connectTimeoutMs = t * 1000;
|
|
1608
|
+
}
|
|
1609
|
+
const clientEncoding = raw.query.get('client_encoding');
|
|
1610
|
+
if (clientEncoding !== undefined && clientEncoding !== '') {
|
|
1611
|
+
out.clientEncoding = clientEncoding;
|
|
1612
|
+
}
|
|
1613
|
+
return out;
|
|
1614
|
+
};
|
|
1615
|
+
// Field map for PG* env vars. Order in the table is documentation; resolution
|
|
1616
|
+
// only depends on whether the var is set.
|
|
1617
|
+
const PG_ENV_FIELD_MAP = {
|
|
1618
|
+
PGHOST: 'host',
|
|
1619
|
+
PGHOSTADDR: 'hostaddr',
|
|
1620
|
+
PGPORT: 'port',
|
|
1621
|
+
PGUSER: 'user',
|
|
1622
|
+
PGDATABASE: 'database',
|
|
1623
|
+
PGPASSWORD: 'password',
|
|
1624
|
+
PGAPPNAME: 'applicationName',
|
|
1625
|
+
PGOPTIONS: 'options',
|
|
1626
|
+
PGCLIENTENCODING: 'clientEncoding',
|
|
1627
|
+
PGSSLMODE: 'ssl',
|
|
1628
|
+
PGSSLROOTCERT: 'sslrootcert',
|
|
1629
|
+
PGSSLCERT: 'sslcert',
|
|
1630
|
+
PGSSLKEY: 'sslkey',
|
|
1631
|
+
PGSSLCERTMODE: 'sslcertmode',
|
|
1632
|
+
PGSSLNEGOTIATION: 'sslnegotiation',
|
|
1633
|
+
PGSSLCRL: 'sslcrl',
|
|
1634
|
+
PGSSLCRLDIR: 'sslcrldir',
|
|
1635
|
+
PGSSLKEYLOGFILE: 'sslkeylogfile',
|
|
1636
|
+
PGCHANNELBINDING: 'channelBinding',
|
|
1637
|
+
};
|
|
1638
|
+
/**
|
|
1639
|
+
* Resolve the PG* env vars into a `Partial<ConnectOptions>`. Only set keys
|
|
1640
|
+
* end up in the result; unset / empty env vars are skipped so the caller
|
|
1641
|
+
* can layer this between URI overrides and pgpass / service / libpq
|
|
1642
|
+
* defaults without clobbering anything.
|
|
1643
|
+
*
|
|
1644
|
+
* Validation: any malformed value (e.g. `PGPORT=abc`) is silently dropped.
|
|
1645
|
+
* libpq behaves the same — the connection then fails later with a clearer
|
|
1646
|
+
* "could not parse" message, but the env-var lookup itself does not throw.
|
|
1647
|
+
*
|
|
1648
|
+
* Notes:
|
|
1649
|
+
* - `PGHOSTADDR` maps to {@link ConnectOptions.hostaddr}: the wire layer
|
|
1650
|
+
* dials this fixed IP while `PGHOST` still drives TLS SNI / cert
|
|
1651
|
+
* verification.
|
|
1652
|
+
* - `PGCONNECT_TIMEOUT` is in seconds; we convert to milliseconds.
|
|
1653
|
+
* - `PGCHANNELBINDING` accepts disable/prefer/require.
|
|
1654
|
+
* - `PGSERVICE` is consumed by the caller (it drives the
|
|
1655
|
+
* pg_service.conf lookup) and is NOT a direct ConnectOptions field.
|
|
1656
|
+
* - `PGSERVICEFILE`, `PGSYSCONFDIR`, `PGPASSFILE` are likewise consumed
|
|
1657
|
+
* by the loaders, not surfaced here.
|
|
1658
|
+
*/
|
|
1659
|
+
export const envConnectionDefaults = (env) => {
|
|
1660
|
+
const out = {};
|
|
1661
|
+
const get = (k) => {
|
|
1662
|
+
const v = env[k];
|
|
1663
|
+
return v !== undefined && v !== '' ? v : undefined;
|
|
1664
|
+
};
|
|
1665
|
+
for (const [envName, field] of Object.entries(PG_ENV_FIELD_MAP)) {
|
|
1666
|
+
const value = get(envName);
|
|
1667
|
+
if (value === undefined)
|
|
1668
|
+
continue;
|
|
1669
|
+
applyEnvValue(out, field, value);
|
|
1670
|
+
}
|
|
1671
|
+
const timeoutRaw = get('PGCONNECT_TIMEOUT');
|
|
1672
|
+
if (timeoutRaw !== undefined) {
|
|
1673
|
+
const t = Number.parseInt(timeoutRaw, 10);
|
|
1674
|
+
if (Number.isFinite(t) && t >= 0)
|
|
1675
|
+
out.connectTimeoutMs = t * 1000;
|
|
1676
|
+
}
|
|
1677
|
+
// GSSAPI is unsupported; PGGSSENCMODE=require is rejected, disable/prefer
|
|
1678
|
+
// accepted-and-ignored. Same contract as the URI/conninfo `gssencmode`.
|
|
1679
|
+
validateGssEncMode(get('PGGSSENCMODE') ?? null);
|
|
1680
|
+
return out;
|
|
1681
|
+
};
|
|
1682
|
+
const applyEnvValue = (out, field, value) => {
|
|
1683
|
+
switch (field) {
|
|
1684
|
+
case 'host':
|
|
1685
|
+
out.host = value;
|
|
1686
|
+
return;
|
|
1687
|
+
case 'port': {
|
|
1688
|
+
const p = Number.parseInt(value, 10);
|
|
1689
|
+
if (Number.isFinite(p) && p > 0 && p <= 65535)
|
|
1690
|
+
out.port = p;
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
case 'user':
|
|
1694
|
+
out.user = value;
|
|
1695
|
+
return;
|
|
1696
|
+
case 'database':
|
|
1697
|
+
out.database = value;
|
|
1698
|
+
return;
|
|
1699
|
+
case 'password':
|
|
1700
|
+
out.password = value;
|
|
1701
|
+
return;
|
|
1702
|
+
case 'applicationName':
|
|
1703
|
+
out.applicationName = value;
|
|
1704
|
+
return;
|
|
1705
|
+
case 'options':
|
|
1706
|
+
out.options = value;
|
|
1707
|
+
return;
|
|
1708
|
+
case 'clientEncoding':
|
|
1709
|
+
out.clientEncoding = value;
|
|
1710
|
+
return;
|
|
1711
|
+
case 'ssl':
|
|
1712
|
+
out.ssl = normalizeSslMode(value);
|
|
1713
|
+
return;
|
|
1714
|
+
case 'sslrootcert':
|
|
1715
|
+
out.sslrootcert = value;
|
|
1716
|
+
return;
|
|
1717
|
+
case 'sslcert':
|
|
1718
|
+
out.sslcert = value;
|
|
1719
|
+
return;
|
|
1720
|
+
case 'sslkey':
|
|
1721
|
+
out.sslkey = value;
|
|
1722
|
+
return;
|
|
1723
|
+
case 'sslcertmode': {
|
|
1724
|
+
const cm = normalizeSslCertMode(value);
|
|
1725
|
+
if (cm !== undefined)
|
|
1726
|
+
out.sslcertmode = cm;
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
case 'sslnegotiation': {
|
|
1730
|
+
const sn = normalizeSslNegotiation(value);
|
|
1731
|
+
if (sn !== undefined)
|
|
1732
|
+
out.sslnegotiation = sn;
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
case 'sslcrl':
|
|
1736
|
+
out.sslcrl = value;
|
|
1737
|
+
return;
|
|
1738
|
+
case 'sslcrldir':
|
|
1739
|
+
out.sslcrldir = value;
|
|
1740
|
+
return;
|
|
1741
|
+
case 'sslkeylogfile':
|
|
1742
|
+
out.sslkeylogfile = value;
|
|
1743
|
+
return;
|
|
1744
|
+
case 'hostaddr':
|
|
1745
|
+
out.hostaddr = value;
|
|
1746
|
+
return;
|
|
1747
|
+
case 'channelBinding': {
|
|
1748
|
+
const cb = normalizeChannelBinding(value);
|
|
1749
|
+
if (cb !== undefined)
|
|
1750
|
+
out.channelBinding = cb;
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
default:
|
|
1754
|
+
// Unhandled field — silently drop. Tightening this would require
|
|
1755
|
+
// narrowing the field-map type; not worth the complexity.
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
/**
|
|
1760
|
+
* libpq compiled-in defaults. The lowest-priority layer in the merge chain.
|
|
1761
|
+
*
|
|
1762
|
+
* - host: 'localhost'
|
|
1763
|
+
* - port: 5432
|
|
1764
|
+
* - user: $USER ?? '' (the wire layer surfaces a clear error if the user
|
|
1765
|
+
* is still empty at connect time)
|
|
1766
|
+
* - database: deferred — libpq defaults dbname to the user; we wire that
|
|
1767
|
+
* in `mergeConnectOptions` after layering so a `PGUSER` env can flow
|
|
1768
|
+
* into `database` when the user didn't specify one.
|
|
1769
|
+
* - ssl: 'prefer'
|
|
1770
|
+
* - applicationName: 'psql' — matches upstream so `pg_stat_activity` shows
|
|
1771
|
+
* the value users expect.
|
|
1772
|
+
* - sslcert / sslkey: libpq auto-loads the default client cert/key at
|
|
1773
|
+
* `~/.postgresql/postgresql.crt` / `.key` when neither is configured AND
|
|
1774
|
+
* the file exists. We seed these as the lowest-priority defaults via
|
|
1775
|
+
* {@link defaultClientCertDefaults}; any explicit URI / env / conninfo
|
|
1776
|
+
* value overrides them. A non-existent default file is simply not set
|
|
1777
|
+
* (no error), matching libpq — only an explicit path that's missing
|
|
1778
|
+
* surfaces an error (at TLS-load time, in the wire layer).
|
|
1779
|
+
*/
|
|
1780
|
+
export const libpqConnectionDefaults = (env) => ({
|
|
1781
|
+
host: 'localhost',
|
|
1782
|
+
port: 5432,
|
|
1783
|
+
user: env.USER ?? '',
|
|
1784
|
+
database: '',
|
|
1785
|
+
ssl: 'prefer',
|
|
1786
|
+
applicationName: 'psql',
|
|
1787
|
+
...defaultClientCertDefaults(env),
|
|
1788
|
+
});
|
|
1789
|
+
/**
|
|
1790
|
+
* libpq default client-certificate discovery. When the user has NOT set
|
|
1791
|
+
* `sslcert` / `sslkey` (explicit paths and `PGSSLCERT` / `PGSSLKEY` are
|
|
1792
|
+
* higher-priority layers), libpq falls back to `~/.postgresql/postgresql.crt`
|
|
1793
|
+
* and `~/.postgresql/postgresql.key` — but only if those files actually
|
|
1794
|
+
* exist. We mirror that here so a present default cert satisfies e.g.
|
|
1795
|
+
* `sslcertmode=require`.
|
|
1796
|
+
*
|
|
1797
|
+
* The home directory is taken from `env.HOME` (falling back to
|
|
1798
|
+
* `os.homedir()`), the same convention as the pgpass / pgservice loaders;
|
|
1799
|
+
* passing a synthetic `HOME` keeps this hermetic in tests.
|
|
1800
|
+
*
|
|
1801
|
+
* Exported for unit testing.
|
|
1802
|
+
*/
|
|
1803
|
+
export const defaultClientCertDefaults = (env) => {
|
|
1804
|
+
const home = env.HOME ?? os.homedir();
|
|
1805
|
+
if (home === undefined || home === '')
|
|
1806
|
+
return {};
|
|
1807
|
+
const out = {};
|
|
1808
|
+
const certPath = path.join(home, '.postgresql', 'postgresql.crt');
|
|
1809
|
+
if (existsSync(certPath))
|
|
1810
|
+
out.sslcert = certPath;
|
|
1811
|
+
const keyPath = path.join(home, '.postgresql', 'postgresql.key');
|
|
1812
|
+
if (existsSync(keyPath))
|
|
1813
|
+
out.sslkey = keyPath;
|
|
1814
|
+
return out;
|
|
1815
|
+
};
|
|
1816
|
+
/**
|
|
1817
|
+
* Translate a `pg_service.conf` entry into a `Partial<ConnectOptions>`.
|
|
1818
|
+
* Unknown keys are silently dropped — the service file format admits
|
|
1819
|
+
* arbitrary keys but only the libpq-spec subset maps to ConnectOptions.
|
|
1820
|
+
*
|
|
1821
|
+
* Numeric / enum validation mirrors `parseConninfo` so an out-of-range
|
|
1822
|
+
* port or bogus sslmode in the service file fails the same way (silently
|
|
1823
|
+
* dropped here, since libpq itself only warns on invalid service values).
|
|
1824
|
+
*/
|
|
1825
|
+
export const serviceEntryToConnectOptions = (entry) => {
|
|
1826
|
+
const out = {};
|
|
1827
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
1828
|
+
const key = k.toLowerCase();
|
|
1829
|
+
switch (key) {
|
|
1830
|
+
case 'host':
|
|
1831
|
+
if (v !== '')
|
|
1832
|
+
out.host = v;
|
|
1833
|
+
break;
|
|
1834
|
+
case 'port': {
|
|
1835
|
+
const p = Number.parseInt(v, 10);
|
|
1836
|
+
if (Number.isFinite(p) && p > 0 && p <= 65535)
|
|
1837
|
+
out.port = p;
|
|
1838
|
+
break;
|
|
1839
|
+
}
|
|
1840
|
+
case 'user':
|
|
1841
|
+
if (v !== '')
|
|
1842
|
+
out.user = v;
|
|
1843
|
+
break;
|
|
1844
|
+
case 'dbname':
|
|
1845
|
+
if (v !== '')
|
|
1846
|
+
out.database = v;
|
|
1847
|
+
break;
|
|
1848
|
+
case 'password':
|
|
1849
|
+
out.password = v;
|
|
1850
|
+
break;
|
|
1851
|
+
case 'application_name':
|
|
1852
|
+
if (v !== '')
|
|
1853
|
+
out.applicationName = v;
|
|
1854
|
+
break;
|
|
1855
|
+
case 'sslmode':
|
|
1856
|
+
if (v !== '')
|
|
1857
|
+
out.ssl = normalizeSslMode(v);
|
|
1858
|
+
break;
|
|
1859
|
+
case 'channel_binding': {
|
|
1860
|
+
const cb = normalizeChannelBinding(v);
|
|
1861
|
+
if (cb !== undefined)
|
|
1862
|
+
out.channelBinding = cb;
|
|
1863
|
+
break;
|
|
1864
|
+
}
|
|
1865
|
+
case 'require_auth': {
|
|
1866
|
+
const ra = normalizeRequireAuth(v);
|
|
1867
|
+
if (ra !== undefined)
|
|
1868
|
+
out.requireAuth = ra;
|
|
1869
|
+
break;
|
|
1870
|
+
}
|
|
1871
|
+
case 'options':
|
|
1872
|
+
if (v !== '')
|
|
1873
|
+
out.options = v;
|
|
1874
|
+
break;
|
|
1875
|
+
case 'client_encoding':
|
|
1876
|
+
if (v !== '')
|
|
1877
|
+
out.clientEncoding = v;
|
|
1878
|
+
break;
|
|
1879
|
+
case 'sslcert':
|
|
1880
|
+
if (v !== '')
|
|
1881
|
+
out.sslcert = v;
|
|
1882
|
+
break;
|
|
1883
|
+
case 'sslkey':
|
|
1884
|
+
if (v !== '')
|
|
1885
|
+
out.sslkey = v;
|
|
1886
|
+
break;
|
|
1887
|
+
case 'sslcertmode': {
|
|
1888
|
+
const cm = normalizeSslCertMode(v);
|
|
1889
|
+
if (cm !== undefined)
|
|
1890
|
+
out.sslcertmode = cm;
|
|
1891
|
+
break;
|
|
1892
|
+
}
|
|
1893
|
+
case 'sslnegotiation': {
|
|
1894
|
+
const sn = normalizeSslNegotiation(v);
|
|
1895
|
+
if (sn !== undefined)
|
|
1896
|
+
out.sslnegotiation = sn;
|
|
1897
|
+
break;
|
|
1898
|
+
}
|
|
1899
|
+
case 'sslrootcert':
|
|
1900
|
+
if (v !== '')
|
|
1901
|
+
out.sslrootcert = v;
|
|
1902
|
+
break;
|
|
1903
|
+
case 'sslcrl':
|
|
1904
|
+
if (v !== '')
|
|
1905
|
+
out.sslcrl = v;
|
|
1906
|
+
break;
|
|
1907
|
+
case 'sslcrldir':
|
|
1908
|
+
if (v !== '')
|
|
1909
|
+
out.sslcrldir = v;
|
|
1910
|
+
break;
|
|
1911
|
+
case 'sslkeylogfile':
|
|
1912
|
+
if (v !== '')
|
|
1913
|
+
out.sslkeylogfile = v;
|
|
1914
|
+
break;
|
|
1915
|
+
case 'sslsni': {
|
|
1916
|
+
const b = parseLibpqBool(v);
|
|
1917
|
+
if (b !== undefined)
|
|
1918
|
+
out.sslsni = b;
|
|
1919
|
+
break;
|
|
1920
|
+
}
|
|
1921
|
+
case 'keepalives': {
|
|
1922
|
+
const b = parseLibpqBool(v);
|
|
1923
|
+
if (b !== undefined)
|
|
1924
|
+
out.keepalives = b;
|
|
1925
|
+
break;
|
|
1926
|
+
}
|
|
1927
|
+
case 'keepalives_idle': {
|
|
1928
|
+
const n = parseKeepalivesIdle(v);
|
|
1929
|
+
if (n !== undefined)
|
|
1930
|
+
out.keepalivesIdle = n;
|
|
1931
|
+
break;
|
|
1932
|
+
}
|
|
1933
|
+
case 'requirepeer':
|
|
1934
|
+
if (v !== '')
|
|
1935
|
+
out.requirepeer = v;
|
|
1936
|
+
break;
|
|
1937
|
+
case 'hostaddr':
|
|
1938
|
+
if (v !== '')
|
|
1939
|
+
out.hostaddr = v;
|
|
1940
|
+
break;
|
|
1941
|
+
case 'ssl_min_protocol_version': {
|
|
1942
|
+
const pv = normalizeTlsProtocolVersion(v === '' ? undefined : v, 'ssl_min_protocol_version');
|
|
1943
|
+
if (pv !== undefined)
|
|
1944
|
+
out.sslMinProtocolVersion = pv;
|
|
1945
|
+
break;
|
|
1946
|
+
}
|
|
1947
|
+
case 'ssl_max_protocol_version': {
|
|
1948
|
+
const pv = normalizeTlsProtocolVersion(v === '' ? undefined : v, 'ssl_max_protocol_version');
|
|
1949
|
+
if (pv !== undefined)
|
|
1950
|
+
out.sslMaxProtocolVersion = pv;
|
|
1951
|
+
break;
|
|
1952
|
+
}
|
|
1953
|
+
case 'connect_timeout': {
|
|
1954
|
+
const t = Number.parseInt(v, 10);
|
|
1955
|
+
if (Number.isFinite(t) && t >= 0)
|
|
1956
|
+
out.connectTimeoutMs = t * 1000;
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
// Recognised but not mapped — service files may contain `passfile`,
|
|
1960
|
+
// `krbsrvname`, etc. We drop silently rather than complain.
|
|
1961
|
+
default:
|
|
1962
|
+
break;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
assertTlsProtocolRange(out.sslMinProtocolVersion, out.sslMaxProtocolVersion);
|
|
1966
|
+
assertTlsMaxProtocolSupported(out.sslMaxProtocolVersion);
|
|
1967
|
+
return out;
|
|
1968
|
+
};
|
|
1969
|
+
/**
|
|
1970
|
+
* Merge layered partial ConnectOptions into a complete ConnectOptions.
|
|
1971
|
+
*
|
|
1972
|
+
* Layers are listed in PRIORITY order (highest first). For each output
|
|
1973
|
+
* field, the first layer that supplies a value wins. The implementation
|
|
1974
|
+
* walks the layers in reverse so the spread-into semantics match.
|
|
1975
|
+
*
|
|
1976
|
+
* `database` has a libpq-specific fallback: if every layer omits it, the
|
|
1977
|
+
* default is the resolved `user` (so `psql -U alice` connects to a
|
|
1978
|
+
* database named `alice`). We apply this AFTER all layers have run.
|
|
1979
|
+
*/
|
|
1980
|
+
export const mergeConnectOptions = (layers, defaults) => {
|
|
1981
|
+
let out = { ...defaults };
|
|
1982
|
+
// Apply layers from LOWEST → HIGHEST so higher-priority layers overwrite.
|
|
1983
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
1984
|
+
const layer = layers[i];
|
|
1985
|
+
out = { ...out, ...layer };
|
|
1986
|
+
}
|
|
1987
|
+
// libpq: database defaults to the resolved user when no layer set it.
|
|
1988
|
+
// The default `database: ''` from `libpqConnectionDefaults` is the
|
|
1989
|
+
// sentinel for "nothing supplied".
|
|
1990
|
+
if (out.database === '') {
|
|
1991
|
+
out.database = out.user;
|
|
1992
|
+
}
|
|
1993
|
+
// libpq: `sslrootcert=system` raises the effective sslmode to verify-full
|
|
1994
|
+
// (it makes no sense to trust the public CA store without verifying the
|
|
1995
|
+
// chain AND the hostname). verify-full is the strongest mode, so this can
|
|
1996
|
+
// only ever raise — never downgrade — an explicitly requested mode.
|
|
1997
|
+
if (out.sslrootcert === 'system' && out.ssl !== 'verify-full') {
|
|
1998
|
+
out.ssl = 'verify-full';
|
|
1999
|
+
}
|
|
2000
|
+
// libpq validates `sslnegotiation=direct` against the FINAL sslmode (after
|
|
2001
|
+
// any `sslrootcert=system` raise and cross-layer merge), rejecting a weak
|
|
2002
|
+
// mode that could end up plaintext. Authoritative check across all layers.
|
|
2003
|
+
assertSslNegotiationModeCompatible(out.ssl, out.sslnegotiation);
|
|
2004
|
+
return out;
|
|
2005
|
+
};
|