Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apple/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,15 @@ internal enum LibPQConnectionString {
applicationName: String? = nil,
connectTimeoutSeconds: Int? = nil
) -> String {
var parameters: [(String, String)] = [
("host", host),
var parameters: [(String, String)] = [("host", host)]
if host.hasPrefix("/") {
// An inherited PGHOSTADDR would otherwise send a socket connection over TCP.
parameters.append(("hostaddr", ""))
}
parameters.append(contentsOf: [
("port", String(port)),
("dbname", database)
]
])

if !user.isEmpty {
parameters.append(("user", user))
Expand Down
16 changes: 14 additions & 2 deletions apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ final class LibPQDriverCore: @unchecked Sendable {
// MARK: - Connection

func connect() async throws {
let server = try Self.serverEndpoint(for: config)
let pqConn = LibPQPluginConnection(
host: config.host,
port: config.port,
host: server.host,
port: server.port,
user: config.username,
password: config.password.isEmpty ? nil : config.password,
database: config.database,
Expand Down Expand Up @@ -144,6 +145,17 @@ final class LibPQDriverCore: @unchecked Sendable {
return PostgreSQLSchemaQueries.probe(rows: result?.rows)
}

static func serverEndpoint(for config: DriverConnectionConfig) throws -> (host: String, port: Int) {
guard let path = PostgreSQLLocalSocket.path(in: config.additionalFields) else {
return (config.host, config.port)
}
let socket = PostgreSQLLocalSocket.endpoint(path: path, port: config.port)
if let issue = PostgreSQLLocalSocket.issue(for: socket) {
throw LibPQPluginError(message: issue.message, sqlState: nil, detail: nil)
}
return (socket.directory, socket.port)
}

func applySchema(_ schema: String) async throws {
_ = try await execute(query: PostgreSQLSchemaQueries.setSearchPath(toSchema: schema))
selectedSchema = schema
Expand Down
103 changes: 103 additions & 0 deletions apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLLocalSocket.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// PostgreSQLLocalSocket.swift
// PostgreSQLDriverPlugin
//

import Foundation

// libpq takes the directory as `host` and dials `<directory>/.s.PGSQL.<port>`: apple/scripts/probes/check-postgresql-local-socket.sh.
nonisolated internal enum PostgreSQLLocalSocket {
// The app's socket key, MySQLLocalSocket.fieldKey, which this plugin does not compile.
static let fieldKey = "localSocketPath"
// libpq's DEFAULT_PGSOCKET_DIR, where Homebrew and Postgres.app put the socket.
static let defaultDirectory = "/tmp"
static let commonDirectories = [defaultDirectory, "/var/run/postgresql"]
// Darwin's `sun_path` is 104 bytes and the last one is the terminating NUL.
static let maximumPathBytes = 103

private static let fileNamePrefix = ".s.PGSQL."

struct Endpoint: Equatable, Sendable {
let directory: String
let port: Int

var socketFile: String {
(directory == "/" ? "" : directory) + "/" + PostgreSQLLocalSocket.fileName(port: port)
}
}

enum PathIssue: Equatable, Sendable {
case notAbsolute
case hiddenCharacter
case comma
case tooLong(bytes: Int)

var message: String {
switch self {
case .notAbsolute:
return String(localized: "The socket path must start with /.")
case .hiddenCharacter:
return String(localized: "The socket path contains a control or invisible character.")
case .comma:
return String(localized: "The socket path cannot contain a comma.")
case .tooLong(let bytes):
return String(
format: String(localized: "The socket path is %d bytes. macOS allows %d at most."),
bytes,
PostgreSQLLocalSocket.maximumPathBytes
)
}
}
}

static func fileName(port: Int) -> String {
fileNamePrefix + String(port)
}

// libpq looks up no host and its default socket directory in ~/.pgpass as `localhost`.
static func pgpassHost(forHost host: String) -> String {
host.isEmpty || host == defaultDirectory ? "localhost" : host
}

static func path(in fields: [String: String]) -> String? {
guard let path = fields[fieldKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty
else { return nil }
return path
}

// A path to the socket file itself gives its directory, and the port in its name wins.
static func endpoint(path: String, port: Int) -> Endpoint {
var trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
while trimmed.count > 1, trimmed.hasSuffix("/") {
trimmed.removeLast()
}
let name = (trimmed as NSString).lastPathComponent
if trimmed.hasPrefix("/"), name.hasPrefix(fileNamePrefix), let filePort = portInFileName(name) {
let parent = (trimmed as NSString).deletingLastPathComponent
return Endpoint(directory: parent.isEmpty ? "/" : parent, port: filePort)
}
return Endpoint(directory: trimmed, port: port)
}

static func issue(for endpoint: Endpoint) -> PathIssue? {
let directory = endpoint.directory
guard directory.hasPrefix("/") else { return .notAbsolute }
let hidden: Set<Unicode.GeneralCategory> = [.control, .format]
guard !directory.unicodeScalars.contains(where: { hidden.contains($0.properties.generalCategory) }) else {
return .hiddenCharacter
}
guard !directory.contains(",") else { return .comma }
let bytes = endpoint.socketFile.utf8.count
guard bytes <= maximumPathBytes else { return .tooLong(bytes: bytes) }
return nil
}

private static func portInFileName(_ name: String) -> Int? {
let digits = name.dropFirst(fileNamePrefix.count)
guard !digits.isEmpty, digits.allSatisfy(\.isASCII), digits.allSatisfy(\.isNumber),
let port = Int(digits), (1...65_535).contains(port)
else { return nil }
return port
}
}
124 changes: 124 additions & 0 deletions apple/Plugins/PostgreSQLDriverTests/PostgreSQLLocalSocketTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//
// PostgreSQLLocalSocketTests.swift
// TableProTests
//

import Foundation
import TableProPluginKit
import Testing

struct PostgreSQLLocalSocketTests {
private func config(socket: String?, host: String = "localhost", port: Int = 5_432) -> DriverConnectionConfig {
DriverConnectionConfig(
host: host,
port: port,
username: "",
password: "",
database: "app",
additionalFields: socket.map { [PostgreSQLLocalSocket.fieldKey: $0] } ?? [:]
)
}

@Test("A directory keeps the connection's port and names the file after it")
func directory() {
let endpoint = PostgreSQLLocalSocket.endpoint(path: "/var/run/postgresql", port: 5_433)
#expect(endpoint == .init(directory: "/var/run/postgresql", port: 5_433))
#expect(endpoint.socketFile == "/var/run/postgresql/.s.PGSQL.5433")
}

@Test("The socket file itself gives its directory, and the port in its name wins")
func socketFile() {
let endpoint = PostgreSQLLocalSocket.endpoint(path: "/tmp/.s.PGSQL.5433", port: 5_432)
#expect(endpoint == .init(directory: "/tmp", port: 5_433))
#expect(PostgreSQLLocalSocket.endpoint(path: "/.s.PGSQL.6000", port: 5_432) == .init(directory: "/", port: 6_000))
#expect(PostgreSQLLocalSocket.endpoint(path: "/", port: 5_432).socketFile == "/.s.PGSQL.5432")
}

@Test("A trailing slash and surrounding space are dropped")
func trailingSlash() {
#expect(PostgreSQLLocalSocket.endpoint(path: " /tmp/ \n", port: 5_432) == .init(directory: "/tmp", port: 5_432))
#expect(PostgreSQLLocalSocket.endpoint(path: "/tmp//", port: 5_432) == .init(directory: "/tmp", port: 5_432))
}

@Test(
"A name that is not a socket file stays a directory",
arguments: ["/tmp/.s.PGSQL.", "/tmp/.s.PGSQL.54x", "/tmp/.s.PGSQL.+5432", "/tmp/.s.PGSQL.0", "/tmp/.s.PGSQL.70000", "/tmp/.s.PGSQL.٥٤٣٢"]
)
func notASocketFileName(_ path: String) {
#expect(PostgreSQLLocalSocket.endpoint(path: path, port: 5_432) == .init(directory: path, port: 5_432))
}

@Test("A relative path, a hidden character and a comma are rejected")
func issues() {
func issue(_ path: String) -> PostgreSQLLocalSocket.PathIssue? {
PostgreSQLLocalSocket.issue(for: PostgreSQLLocalSocket.endpoint(path: path, port: 5_432))
}
#expect(issue("tmp") == .notAbsolute)
#expect(issue(".s.PGSQL.5432") == .notAbsolute)
#expect(issue("run/.s.PGSQL.5432") == .notAbsolute)
#expect(issue("~/run") == .notAbsolute)
#expect(issue("/tmp/a\u{0}b") == .hiddenCharacter)
#expect(issue("/tmp/\u{202E}x") == .hiddenCharacter)
#expect(issue("/tmp,/var/run/postgresql") == .comma)
#expect(issue("/tmp") == nil)
#expect(issue("/Users/me/Library/Application Support/Postgres/var-17") == nil)
}

@Test("The length that counts is the socket file's, which macOS caps at 103 bytes")
func length() {
let fits = "/" + String(repeating: "a", count: 88)
#expect(PostgreSQLLocalSocket.endpoint(path: fits, port: 5_432).socketFile.utf8.count == 103)
#expect(PostgreSQLLocalSocket.issue(for: .init(directory: fits, port: 5_432)) == nil)
#expect(PostgreSQLLocalSocket.issue(for: .init(directory: fits, port: 54_321)) == .tooLong(bytes: 104))
}

@Test("~/.pgpass looks no host and /tmp up as localhost, any other directory by its path")
func pgpassHost() {
#expect(PostgreSQLLocalSocket.pgpassHost(forHost: "") == "localhost")
#expect(PostgreSQLLocalSocket.pgpassHost(forHost: "/tmp") == "localhost")
#expect(PostgreSQLLocalSocket.pgpassHost(forHost: "/var/run/postgresql") == "/var/run/postgresql")
#expect(PostgreSQLLocalSocket.pgpassHost(forHost: "db.example.com") == "db.example.com")
}

@Test("A socket field hands libpq the directory as host and the port its file is named after")
func driverEndpoint() throws {
let server = try LibPQDriverCore.serverEndpoint(for: config(socket: "/tmp/.s.PGSQL.5433", host: "db.example.com"))
#expect(server.host == "/tmp")
#expect(server.port == 5_433)

let directory = try LibPQDriverCore.serverEndpoint(for: config(socket: "/var/run/postgresql", port: 5_434))
#expect(directory.host == "/var/run/postgresql")
#expect(directory.port == 5_434)
}

@Test("Without a socket the host and port pass through")
func driverEndpointWithoutSocket() throws {
for socket in [nil, "", " "] as [String?] {
let server = try LibPQDriverCore.serverEndpoint(for: config(socket: socket, host: "db.example.com", port: 6_432))
#expect(server.host == "db.example.com")
#expect(server.port == 6_432)
}
}

@Test("A socket host pins hostaddr empty, so an inherited PGHOSTADDR cannot turn it into TCP")
func socketPinsHostaddr() {
func conninfo(host: String) -> String {
LibPQConnectionString.build(
host: host, port: 5_432, user: "", password: nil, database: "app",
sslConfig: SSLConfiguration(), options: nil
)
}
#expect(conninfo(host: "/tmp").hasPrefix("host='/tmp' hostaddr='' port='5432'"))
#expect(!conninfo(host: "db.example.com").contains("hostaddr"))
}

@Test("A path libpq would misread fails before it is dialed")
func driverRejectsBadPath() {
#expect(throws: LibPQPluginError.self) {
try LibPQDriverCore.serverEndpoint(for: config(socket: "/tmp,/var/run/postgresql"))
}
#expect(throws: LibPQPluginError.self) {
try LibPQDriverCore.serverEndpoint(for: config(socket: "relative/dir"))
}
}
}
32 changes: 27 additions & 5 deletions apple/TablePro/Core/Database/ConnectionCredentialResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import TableProGoogleCloud
import TableProPluginKit
import TableProUtilities

struct PgpassLookup: Hashable {
let host: String
let port: Int
let database: String
let username: String
}

/// The one answer to "what password does this connection sign in with".
///
/// A connection can get its password from five places: an AWS or Cloud SQL IAM token minted per
Expand Down Expand Up @@ -111,14 +118,29 @@ enum ConnectionCredentialResolver {
}

private static func pgpassPassword(for connection: DatabaseConnection, username: String) -> String {
let host = connection.preTunnelHost ?? connection.host
let port = connection.preTunnelPort ?? connection.port
let lookup = pgpassLookup(for: connection, username: username)
return PgpassReader.resolve(
host: host.isEmpty ? "localhost" : host,
host: lookup.host,
port: lookup.port,
database: lookup.database,
username: lookup.username
) ?? ""
}

// What libpq looks up in ~/.pgpass: a socket by its directory, a blank user as the login name.
nonisolated static func pgpassLookup(for connection: DatabaseConnection, username: String) -> PgpassLookup {
var host = connection.preTunnelHost ?? connection.host
var port = connection.preTunnelPort ?? connection.port
if connection.type.localSocketIsDirectory, let socket = connection.localSocketEndpoint {
host = socket.path
port = socket.port
}
return PgpassLookup(
host: PostgreSQLLocalSocket.pgpassHost(forHost: host),
port: port,
database: connection.database,
username: username
) ?? ""
username: PgpassReader.effectiveUsername(username)
)
}

// The AWS token is signed for the resolved username: `connection.username` can lag behind its profile.
Expand Down
6 changes: 5 additions & 1 deletion apple/TablePro/Core/Database/NativeDumpRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,12 @@ enum NativeDumpRegistry {
}
}

// A socket goes to `-h` as its directory, the way libpq reads a host starting with `/`.
private static func connectionFlags(_ request: NativeDumpDescriptor.Request) -> [String] {
var flags = ["--no-password", "-h", request.host, "-p", String(request.connection.port)]
let socket = request.connection.type.localSocketIsDirectory ? request.connection.localSocketEndpoint : nil
let host = socket?.path ?? request.host
let port = socket?.port ?? request.connection.port
var flags = ["--no-password", "-h", host, "-p", String(port)]
if !request.connection.username.isEmpty {
flags.append(contentsOf: ["-U", request.connection.username])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,8 @@ extension PluginMetadataRegistry {
additionalConnectionFields: [pgpassField, connectionOptionsField] + cloudIAMFields,
category: .relational,
tagline: String(localized: "Advanced object-relational SQL"),
defaultUnixSocketPath: "/var/run/postgresql/.s.PGSQL.5432"
defaultUnixSocketPath: "/var/run/postgresql/.s.PGSQL.5432",
defaultLocalSocketPath: PostgreSQLLocalSocket.defaultDirectory
)
)),
("Redshift", PluginMetadataSnapshot(
Expand Down
7 changes: 5 additions & 2 deletions apple/TablePro/Core/Services/Export/ConnectionShareLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ internal enum ConnectionShareLink {
components.host = "import"

var queryItems = [URLQueryItem(name: "name", value: settings.name)]
if let socketPath = connection.localSocketPath {
queryItems.append(URLQueryItem(name: "socket", value: socketPath))
if let socket = connection.localSocketEndpoint {
queryItems.append(URLQueryItem(name: "socket", value: socket.path))
if connection.type.localSocketIsDirectory {
queryItems.append(URLQueryItem(name: "port", value: String(socket.port)))
}
} else {
queryItems.append(URLQueryItem(name: "host", value: settings.host))
queryItems.append(URLQueryItem(name: "port", value: String(settings.port)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@
import Foundation

internal enum DatabaseURLConnectionMatch {
// A socket URL always says localhost and names no port, so only the socket path tells two
// local servers apart, and a TCP URL never opens a socket connection or the other way round.
// A socket URL always says localhost, so only the socket file tells two local servers apart.
internal static func matches(saved: DatabaseConnection, parsed: ParsedConnectionURL) -> Bool {
guard saved.type == parsed.type,
saved.database == parsed.database,
parsed.username.isEmpty || saved.username == parsed.username,
saved.localSocketPath == parsed.localSocketPath
saved.localSocketFile == parsed.localSocketFile
else { return false }
guard parsed.localSocketPath == nil else { return true }
guard parsed.localSocketFile == nil else { return true }
return saved.host == parsed.host && (parsed.port == nil || saved.port == parsed.port)
}
}
Loading
Loading