Skip to content

Commit e738ae2

Browse files
committed
fix(cli): fall back to .pgpass for omitted test db password
pgconn's ParseConfig reads ~/.pgpass (or PGPASSFILE) into config.Password when the password is omitted, and test.go passes it to pg_prove as PGPASSWORD. The parser resolved an omitted password to empty (unless PGPASSWORD was set), so pgpass-authenticated DSNs failed. Add legacy-pgpass (a 1:1 port of jackc/pgpassfile: parse + FindPassword with * wildcards and \/\: escapes, PGPASSFILE/~/.pgpass path, unix-socket→localhost) and resolve the password as connection-string → PGPASSWORD → .pgpass in both parse paths.
1 parent 0d23fb7 commit e738ae2

3 files changed

Lines changed: 172 additions & 5 deletions

File tree

apps/cli/src/legacy/shared/legacy-db-config.parse.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { existsSync } from "node:fs";
22
import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts";
3+
import { legacyPgpassPassword } from "./legacy-pgpass.ts";
34

45
/** Go's `pgconn` default direct Postgres port. */
56
const DIRECT_PORT = 5432;
@@ -143,13 +144,20 @@ function parseUrlConnectionString(value: string): LegacyPgConnInput | undefined
143144
if (port === undefined) {
144145
return undefined;
145146
}
147+
const host = rawHost.length > 0 ? rawHost : (libpqEnv("PGHOST") ?? defaultLibpqHost());
148+
// Absent database → PGDATABASE, then the resolved user (libpq default).
149+
const database = rawDatabase.length > 0 ? rawDatabase : (libpqEnv("PGDATABASE") ?? user);
150+
// Password precedence (pgconn): connection string → PGPASSWORD → `.pgpass`.
151+
const password =
152+
rawPassword.length > 0
153+
? rawPassword
154+
: (libpqEnv("PGPASSWORD") ?? legacyPgpassPassword(host, port, database, user));
146155
return {
147-
host: rawHost.length > 0 ? rawHost : (libpqEnv("PGHOST") ?? defaultLibpqHost()),
156+
host,
148157
port,
149158
user,
150-
password: rawPassword.length > 0 ? rawPassword : (libpqEnv("PGPASSWORD") ?? ""),
151-
// Absent database → PGDATABASE, then the resolved user (libpq default).
152-
database: rawDatabase.length > 0 ? rawDatabase : (libpqEnv("PGDATABASE") ?? user),
159+
password,
160+
database,
153161
...(options !== null && options.length > 0 ? { options } : {}),
154162
...(sslmode !== null && sslmode.length > 0 ? { sslmode } : {}),
155163
...(sslrootcert !== null && sslrootcert.length > 0 ? { sslrootcert } : {}),
@@ -219,11 +227,16 @@ function parseKeywordValueDsn(value: string): LegacyPgConnInput | undefined {
219227
if (isInvalidSslmode(sslmode)) return undefined;
220228
const sslrootcert = params.get("sslrootcert") ?? libpqEnv("PGSSLROOTCERT");
221229
const options = params.get("options");
230+
// Password precedence (pgconn): connection string → PGPASSWORD → `.pgpass`.
231+
const password =
232+
params.get("password") ??
233+
libpqEnv("PGPASSWORD") ??
234+
legacyPgpassPassword(host, port, database, user);
222235
return {
223236
host,
224237
port,
225238
user,
226-
password: params.get("password") ?? libpqEnv("PGPASSWORD") ?? "",
239+
password,
227240
database,
228241
...(options !== undefined && options.length > 0 ? { options } : {}),
229242
...(sslmode !== undefined && sslmode.length > 0 ? { sslmode } : {}),
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { readFileSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
4+
5+
/**
6+
* libpq `.pgpass` password lookup, a 1:1 port of `jackc/pgpassfile`
7+
* (`ParsePassfile` + `FindPassword`) as used by `pgconn.ParseConfig`
8+
* (`config.go:369-378`): when a connection string omits the password, pgconn
9+
* reads the passfile and returns the first entry matching host/port/database/
10+
* user (with `*` wildcards). For a unix-socket host pgconn matches `localhost`.
11+
*/
12+
13+
const TMP_BACKSLASH = "\r";
14+
const TMP_COLON = "\n";
15+
16+
interface PgpassEntry {
17+
readonly hostname: string;
18+
readonly port: string;
19+
readonly database: string;
20+
readonly username: string;
21+
readonly password: string;
22+
}
23+
24+
/**
25+
* Parse a single `.pgpass` line into an entry, or `undefined` for comments and
26+
* unparsable lines. Handles `\\` and `\:` escapes via temporary placeholders,
27+
* then splits on the remaining unescaped colons (must yield exactly 5 fields).
28+
*/
29+
function parsePgpassLine(line: string): PgpassEntry | undefined {
30+
const trimmed = line.trim();
31+
if (trimmed.length === 0 || trimmed.startsWith("#")) {
32+
return undefined;
33+
}
34+
const escaped = trimmed.replaceAll("\\\\", TMP_BACKSLASH).replaceAll("\\:", TMP_COLON);
35+
const parts = escaped.split(":");
36+
if (parts.length !== 5) {
37+
return undefined;
38+
}
39+
const unescape = (part: string): string =>
40+
part.replaceAll(TMP_BACKSLASH, "\\").replaceAll(TMP_COLON, ":");
41+
return {
42+
hostname: unescape(parts[0]!),
43+
port: unescape(parts[1]!),
44+
database: unescape(parts[2]!),
45+
username: unescape(parts[3]!),
46+
password: unescape(parts[4]!),
47+
};
48+
}
49+
50+
/**
51+
* Find the password for the given connection fields in `.pgpass` file contents,
52+
* returning the first matching entry's password (or `""`). Each entry field
53+
* matches when it is `*` or equals the connection field.
54+
*/
55+
export function legacyFindPgpassPassword(
56+
contents: string,
57+
hostname: string,
58+
port: string,
59+
database: string,
60+
username: string,
61+
): string {
62+
for (const line of contents.split("\n")) {
63+
const entry = parsePgpassLine(line);
64+
if (entry === undefined) {
65+
continue;
66+
}
67+
if (
68+
(entry.hostname === "*" || entry.hostname === hostname) &&
69+
(entry.port === "*" || entry.port === port) &&
70+
(entry.database === "*" || entry.database === database) &&
71+
(entry.username === "*" || entry.username === username)
72+
) {
73+
return entry.password;
74+
}
75+
}
76+
return "";
77+
}
78+
79+
/** Resolve the passfile path: `PGPASSFILE`, else the libpq per-OS default. */
80+
function pgpassFilePath(): string | undefined {
81+
const explicit = process.env["PGPASSFILE"];
82+
if (explicit !== undefined && explicit.length > 0) {
83+
return explicit;
84+
}
85+
if (process.platform === "win32") {
86+
const appData = process.env["APPDATA"];
87+
return appData !== undefined && appData.length > 0
88+
? join(appData, "postgresql", "pgpass.conf")
89+
: undefined;
90+
}
91+
const home = homedir();
92+
return home.length > 0 ? join(home, ".pgpass") : undefined;
93+
}
94+
95+
/**
96+
* Resolve a password from the `.pgpass` file for the given connection, or `""`
97+
* when the file is absent/unreadable or has no matching entry. A unix-socket
98+
* host (a path) matches `localhost`, mirroring pgconn's `NetworkAddress`.
99+
*/
100+
export function legacyPgpassPassword(
101+
host: string,
102+
port: number,
103+
database: string,
104+
username: string,
105+
): string {
106+
const path = pgpassFilePath();
107+
if (path === undefined) {
108+
return "";
109+
}
110+
let contents: string;
111+
try {
112+
contents = readFileSync(path, "utf8");
113+
} catch {
114+
return "";
115+
}
116+
const matchHost = host.startsWith("/") ? "localhost" : host;
117+
return legacyFindPgpassPassword(contents, matchHost, String(port), database, username);
118+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { legacyFindPgpassPassword } from "./legacy-pgpass.ts";
4+
5+
describe("legacyFindPgpassPassword", () => {
6+
const file = [
7+
"# a comment",
8+
"db.example.com:5432:appdb:alice:s3cret",
9+
"*:*:*:*:wildcard-pass",
10+
].join("\n");
11+
12+
it("returns the password of the first matching entry", () => {
13+
expect(legacyFindPgpassPassword(file, "db.example.com", "5432", "appdb", "alice")).toBe(
14+
"s3cret",
15+
);
16+
});
17+
18+
it("falls through to a wildcard entry when no exact match", () => {
19+
expect(legacyFindPgpassPassword(file, "other.host", "5432", "db", "bob")).toBe("wildcard-pass");
20+
});
21+
22+
it("returns empty string when nothing matches and no wildcard", () => {
23+
expect(
24+
legacyFindPgpassPassword("db.example.com:5432:appdb:alice:s3cret", "h", "5432", "d", "u"),
25+
).toBe("");
26+
});
27+
28+
it("honors escaped colons and backslashes in fields (jackc/pgpassfile parity)", () => {
29+
// Password `a:b\c` written with escaped colon and backslash.
30+
expect(legacyFindPgpassPassword("h:5432:d:u:a\\:b\\\\c", "h", "5432", "d", "u")).toBe("a:b\\c");
31+
});
32+
33+
it("skips lines that do not have exactly five fields", () => {
34+
expect(legacyFindPgpassPassword("h:5432:d:u", "h", "5432", "d", "u")).toBe("");
35+
});
36+
});

0 commit comments

Comments
 (0)