Skip to content

Instantly share code, notes, and snippets.

@alairock
Created July 8, 2024 20:03
Show Gist options
  • Save alairock/73182e1892694c1cc6c4e1e24e7d25c1 to your computer and use it in GitHub Desktop.
Save alairock/73182e1892694c1cc6c4e1e24e7d25c1 to your computer and use it in GitHub Desktop.
PGPass file reader, typed for TS, Node, JS
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