Skip to content

Commit 5fa7be8

Browse files
authored
Merge pull request #279 from datlechin/feat/pgpass-pre-connect-hook
feat: add ~/.pgpass file support and pre-connect script
2 parents f24289f + 73b4b31 commit 5fa7be8

10 files changed

Lines changed: 670 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Copy as INSERT/UPDATE SQL statements from data grid context menu
2020
- Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour
2121
- MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support
22+
- `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form
23+
- Pre-connect script: run a shell command before each connection (e.g., to refresh credentials or update ~/.pgpass)
2224

2325
### Fixed
2426

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,14 +319,23 @@ enum DatabaseDriverFactory {
319319
host: connection.host,
320320
port: connection.port,
321321
username: connection.username,
322-
password: ConnectionStorage.shared.loadPassword(for: connection.id) ?? "",
322+
password: resolvePassword(for: connection),
323323
database: connection.database,
324324
additionalFields: buildAdditionalFields(for: connection, plugin: plugin)
325325
)
326326
let pluginDriver = plugin.createDriver(config: config)
327327
return PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver)
328328
}
329329

330+
private static func resolvePassword(for connection: DatabaseConnection) -> String {
331+
if connection.usePgpass
332+
&& (connection.type == .postgresql || connection.type == .redshift)
333+
{
334+
return ""
335+
}
336+
return ConnectionStorage.shared.loadPassword(for: connection.id) ?? ""
337+
}
338+
330339
private static func buildAdditionalFields(
331340
for connection: DatabaseConnection,
332341
plugin: any DriverPlugin

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ final class DatabaseManager {
138138
throw error
139139
}
140140

141+
// Run pre-connect hook if configured (only on explicit connect, not auto-reconnect)
142+
if let script = connection.preConnectScript,
143+
!script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
144+
{
145+
do {
146+
try await PreConnectHookRunner.run(script: script)
147+
} catch {
148+
activeSessions.removeValue(forKey: connection.id)
149+
currentSessionId = nil
150+
throw error
151+
}
152+
}
153+
141154
// Create appropriate driver with effective connection
142155
let driver = try DatabaseDriverFactory.createDriver(for: effectiveConnection)
143156

@@ -411,7 +424,8 @@ final class DatabaseManager {
411424
username: connection.username,
412425
type: connection.type,
413426
sshConfig: SSHConfiguration(),
414-
sslConfig: tunnelSSL
427+
sslConfig: tunnelSSL,
428+
additionalFields: connection.additionalFields
415429
)
416430
}
417431

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// PreConnectHookRunner.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
/// Runs a shell script before establishing a database connection.
10+
/// Non-zero exit aborts the connection with an error.
11+
enum PreConnectHookRunner {
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "PreConnectHookRunner")
13+
14+
enum HookError: LocalizedError {
15+
case scriptFailed(exitCode: Int32, stderr: String)
16+
case timeout
17+
18+
var errorDescription: String? {
19+
switch self {
20+
case let .scriptFailed(exitCode, stderr):
21+
let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
22+
if message.isEmpty {
23+
return String(localized: "Pre-connect script failed with exit code \(exitCode)")
24+
}
25+
return String(localized: "Pre-connect script failed (exit \(exitCode)): \(message)")
26+
case .timeout:
27+
return String(localized: "Pre-connect script timed out after 10 seconds")
28+
}
29+
}
30+
}
31+
32+
/// Run a shell script before connecting. Throws on non-zero exit or timeout.
33+
/// Executes on a background thread to avoid blocking the MainActor.
34+
static func run(script: String, environment: [String: String]? = nil) async throws {
35+
logger.info("Running pre-connect script")
36+
37+
try await Task.detached {
38+
let process = Process()
39+
process.executableURL = URL(fileURLWithPath: "/bin/bash")
40+
process.arguments = ["-c", script]
41+
42+
var env = ProcessInfo.processInfo.environment
43+
if let environment {
44+
for (key, value) in environment {
45+
env[key] = value
46+
}
47+
}
48+
process.environment = env
49+
50+
let stderrPipe = Pipe()
51+
process.standardError = stderrPipe
52+
process.standardOutput = FileHandle.nullDevice
53+
54+
// Drain stderr on a background thread to prevent pipe deadlock.
55+
// If the child writes >64KB to stderr without the parent reading,
56+
// the pipe buffer fills and the child blocks on write — deadlocking
57+
// with waitUntilExit() on the parent side.
58+
let stderrCollector = StderrCollector()
59+
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
60+
let chunk = handle.availableData
61+
if !chunk.isEmpty {
62+
stderrCollector.append(chunk)
63+
}
64+
}
65+
66+
try process.run()
67+
68+
// 10-second timeout on a separate detached task
69+
let timeoutTask = Task.detached {
70+
try await Task.sleep(nanoseconds: 10_000_000_000)
71+
if process.isRunning {
72+
process.terminate()
73+
}
74+
}
75+
76+
process.waitUntilExit()
77+
timeoutTask.cancel()
78+
79+
stderrPipe.fileHandleForReading.readabilityHandler = nil
80+
let stderr = stderrCollector.result
81+
82+
if process.terminationReason == .uncaughtSignal {
83+
throw HookError.timeout
84+
}
85+
86+
if process.terminationStatus != 0 {
87+
throw HookError.scriptFailed(exitCode: process.terminationStatus, stderr: stderr)
88+
}
89+
}.value
90+
91+
logger.info("Pre-connect script completed successfully")
92+
}
93+
}
94+
95+
/// Thread-safe collector for stderr output from a child process.
96+
private final class StderrCollector: @unchecked Sendable {
97+
private let lock = NSLock()
98+
private var data = Data()
99+
100+
func append(_ chunk: Data) {
101+
lock.lock()
102+
data.append(chunk)
103+
lock.unlock()
104+
}
105+
106+
var result: String {
107+
lock.lock()
108+
defer { lock.unlock() }
109+
return String(data: data, encoding: .utf8) ?? ""
110+
}
111+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// PgpassReader.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
/// Reads and parses the standard PostgreSQL ~/.pgpass file
10+
enum PgpassReader {
11+
private static let logger = Logger(subsystem: "com.TablePro", category: "PgpassReader")
12+
13+
/// Whether ~/.pgpass exists
14+
static func fileExists() -> Bool {
15+
let path = NSHomeDirectory() + "/.pgpass"
16+
return FileManager.default.fileExists(atPath: path)
17+
}
18+
19+
/// Whether ~/.pgpass has correct permissions (0600). libpq silently ignores the file otherwise.
20+
static func filePermissionsAreValid() -> Bool {
21+
let path = NSHomeDirectory() + "/.pgpass"
22+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
23+
let posixPerms = attrs[.posixPermissions] as? Int
24+
else {
25+
return false
26+
}
27+
return posixPerms == 0o600
28+
}
29+
30+
/// Resolve a password from ~/.pgpass per PostgreSQL spec.
31+
/// Returns the password from the first matching entry, or nil if no match.
32+
/// Format: hostname:port:database:username:password
33+
/// Wildcard `*` matches any value in a field. First match wins.
34+
static func resolve(host: String, port: Int, database: String, username: String) -> String? {
35+
let path = NSHomeDirectory() + "/.pgpass"
36+
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
37+
logger.debug("Could not read ~/.pgpass")
38+
return nil
39+
}
40+
41+
for line in contents.components(separatedBy: .newlines) {
42+
let trimmed = line.trimmingCharacters(in: .whitespaces)
43+
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
44+
45+
let fields = parseFields(from: trimmed)
46+
guard fields.count == 5 else { continue }
47+
48+
if matches(fields[0], value: host)
49+
&& matches(fields[1], value: String(port))
50+
&& matches(fields[2], value: database)
51+
&& matches(fields[3], value: username)
52+
{
53+
return fields[4]
54+
}
55+
}
56+
57+
return nil
58+
}
59+
60+
/// Parse a pgpass line into fields, handling escaped colons (\:) and backslashes (\\)
61+
private static func parseFields(from line: String) -> [String] {
62+
var fields: [String] = []
63+
var current = ""
64+
var escaped = false
65+
66+
for char in line {
67+
if escaped {
68+
current.append(char)
69+
escaped = false
70+
} else if char == "\\" {
71+
escaped = true
72+
} else if char == ":" {
73+
fields.append(current)
74+
current = ""
75+
} else {
76+
current.append(char)
77+
}
78+
}
79+
fields.append(current)
80+
return fields
81+
}
82+
83+
/// Match a pgpass field value against an actual value. Wildcard "*" matches anything.
84+
private static func matches(_ pattern: String, value: String) -> Bool {
85+
pattern == "*" || pattern == value
86+
}
87+
}

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,16 @@ struct DatabaseConnection: Identifiable, Hashable {
436436
set { additionalFields["oracleServiceName"] = newValue ?? "" }
437437
}
438438

439+
var usePgpass: Bool {
440+
get { additionalFields["usePgpass"] == "true" }
441+
set { additionalFields["usePgpass"] = newValue ? "true" : "" }
442+
}
443+
444+
var preConnectScript: String? {
445+
get { additionalFields["preConnectScript"]?.nilIfEmpty }
446+
set { additionalFields["preConnectScript"] = newValue ?? "" }
447+
}
448+
439449
init(
440450
id: UUID = UUID(),
441451
name: String,

0 commit comments

Comments
 (0)