Created
July 8, 2024 20:03
-
-
Save alairock/73182e1892694c1cc6c4e1e24e7d25c1 to your computer and use it in GitHub Desktop.
PGPass file reader, typed for TS, Node, JS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as path from "path" | |
import fs from "fs/promises" | |
import { getLogger } from "../logging" | |
/** | |
* The default port number for PostgreSQL database connections. | |
*/ | |
const defaultPort = 5432 | |
/** | |
* Constant representing the permission bits for the group in a file mode. | |
* The value is set to 0o070. | |
*/ | |
const S_IRWXG = 0o070 | |
/** | |
* Constant representing the permission mask for "other" users. | |
* The value is set to 0o007, which corresponds to read, write, and execute permissions for "other" users. | |
*/ | |
const S_IRWXO = 0o007 | |
/** | |
* The file type mask for the file mode bits. | |
*/ | |
const S_IFMT = 0o170000 | |
/** | |
* The file type constant for a regular file. | |
*/ | |
const S_IFREG = 0o100000 | |
/** | |
* Checks if the given file mode represents a regular file. | |
* | |
* @param mode - The file mode to check. | |
* @returns `true` if the file mode represents a regular file, `false` otherwise. | |
*/ | |
function isRegFile(mode: number): boolean { | |
return (mode & S_IFMT) === S_IFREG | |
} | |
/** | |
* Array containing the field names for a PostgreSQL connection. | |
* The order of the fields is as follows: host, port, database, user, password. | |
*/ | |
const fieldNames = ["host", "port", "database", "user", "password"] | |
/** | |
* The number of fields in the `fieldNames` array. | |
*/ | |
const nrOfFields = fieldNames.length | |
/** | |
* The key used to access the password field in the `fieldNames` array. | |
*/ | |
const passKey = fieldNames[nrOfFields - 1] | |
// type for connection info | |
export type ConnectionInfo = { | |
host: string | |
port: number | |
database: string | |
user: string | |
password?: string | |
} | |
/** | |
* Retrieves the file name of the PostgreSQL password file. | |
* If the `PGPASSFILE` environment variable is set, it returns its value. | |
* Otherwise, it returns the default file path based on the operating system. | |
* | |
* @param rawEnv - Optional. The raw environment variables object. | |
* @returns The file name of the PostgreSQL password file. | |
*/ | |
export function getFileName(rawEnv?: NodeJS.ProcessEnv): string { | |
const env = rawEnv || process.env | |
const file = | |
env.PGPASSFILE || | |
(process.platform === "win32" | |
? path.join(env.APPDATA || "./", "postgresql", "pgpass.conf") | |
: path.join(env.HOME || "./", ".pgpass")) | |
return file | |
} | |
/** | |
* Checks if the `PGPASSWORD` environment variable is set and returns a boolean value indicating whether to use the `.pgpass` file. | |
* | |
* @param stats - The file stats object containing the mode of the file. | |
* @param fname - The name of the file (optional). | |
* @returns A boolean value indicating whether to use the `.pgpass` file. | |
*/ | |
export function usePgPass(stats: { mode: number }, fname?: string): boolean { | |
if (Object.prototype.hasOwnProperty.call(process.env, "PGPASSWORD")) { | |
return false | |
} | |
if (process.platform === "win32") { | |
return true | |
} | |
fname = fname || "<unkn>" | |
if (!isRegFile(stats.mode)) { | |
getLogger().warn('WARNING: password file "%s" is not a plain file', fname) | |
return false | |
} | |
if (stats.mode & (S_IRWXG | S_IRWXO)) { | |
getLogger().warn( | |
'WARNING: password file "%s" has group or world access; permissions should be u=rw (0600) or less', | |
fname, | |
) | |
return false | |
} | |
return true | |
} | |
/** | |
* Checks if the given connection information matches the provided entry. | |
* @param connInfo - The connection information to match against. | |
* @param entry - The entry to compare with the connection information. | |
* @returns A boolean indicating whether the connection information matches the entry. | |
*/ | |
export function match(connInfo: any, entry: any): boolean { | |
return fieldNames.slice(0, -1).reduce((prev, field, idx) => { | |
if (idx === 1) { | |
// the port | |
if (Number(connInfo[field] || defaultPort) === Number(entry[field])) { | |
return prev && true | |
} | |
} | |
return prev && (entry[field] === "*" || entry[field] === connInfo[field]) | |
}, true) | |
} | |
/** | |
* Parses a line from a pgpass file and returns an object containing key-value pairs. | |
* | |
* @param line - The line to parse. | |
* @returns An object containing key-value pairs if the line is valid, otherwise null. | |
*/ | |
export function parseLine(line: string): { [key: string]: string } | null { | |
if (line.length < 11 || /^\s*#/.test(line)) { | |
return null | |
} | |
let curChar = "" | |
let prevChar = "" | |
let fieldIdx = 0 | |
let startIdx = 0 | |
const obj: { [key: string]: string } = {} | |
let isLastField = false | |
function addToObj(idx: number, i0: number, i1?: number) { | |
let field = line.substring(i0, i1) | |
if (!process.env.PGPASS_NO_DEESCAPE) { | |
field = field.replace(/\\([:\\])/g, "$1") | |
} | |
obj[fieldNames[idx]] = field | |
} | |
for (let i = 0; i < line.length - 1; i += 1) { | |
curChar = line.charAt(i + 1) | |
prevChar = line.charAt(i) | |
isLastField = fieldIdx === nrOfFields - 1 | |
if (isLastField) { | |
addToObj(fieldIdx, startIdx) | |
break | |
} | |
if (i >= 0 && curChar === ":" && prevChar !== "\\") { | |
addToObj(fieldIdx, startIdx, i + 1) | |
startIdx = i + 2 | |
fieldIdx += 1 | |
} | |
} | |
return Object.keys(obj).length === nrOfFields ? obj : null | |
} | |
/** | |
* Checks if the given entry is a valid PostgreSQL password entry. | |
* @param entry - The entry to validate. | |
* @returns `true` if the entry is valid, `false` otherwise. | |
*/ | |
export function isValidEntry(entry: { [key: string]: string }): boolean { | |
const rules: { [key: number]: (x: string) => boolean } = { | |
// host | |
0: x => x.length > 0, | |
// port | |
1: x => { | |
if (x === "*") { | |
return true | |
} | |
const num = Number(x) | |
return isFinite(num) && num > 0 && num < 9007199254740992 && Math.floor(num) === num | |
}, | |
// database | |
2: x => x.length > 0, | |
// username | |
3: x => x.length > 0, | |
// password | |
4: x => x.length > 0, | |
} | |
for (let idx = 0; idx < fieldNames.length; idx += 1) { | |
const rule = rules[idx] | |
const value = entry[fieldNames[idx]] || "" | |
if (!rule(value)) { | |
return false | |
} | |
} | |
return true | |
} | |
export async function pgpass(connInfo: ConnectionInfo): Promise<ConnectionInfo | Error> { | |
const file = getFileName() | |
let pass: string | undefined | |
try { | |
const data = await fs.readFile(file, "utf8") | |
const lines = data.split("\n") | |
for (const line of lines) { | |
const entry = parseLine(line) | |
if (entry && isValidEntry(entry) && match(connInfo, entry)) { | |
pass = entry[passKey] | |
break | |
} | |
} | |
} catch (err) { | |
getLogger().warn("Error reading pgpass file", err) | |
return new Error("Error reading pgpass file") | |
} | |
if (!pass) { | |
return new Error("No password found in pgpass file") | |
} | |
connInfo.password = pass | |
// url decoded password | |
connInfo.password = connInfo.password.replace(/\\([:\\])/g, "$1") | |
return connInfo | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment