diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift index 9400ee32ba..d8e0bd8412 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQConnectionString.swift @@ -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)) diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index ae5a6b0da2..77c8a0708b 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -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, @@ -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 diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLLocalSocket.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLLocalSocket.swift new file mode 100644 index 0000000000..0cd0dff195 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLLocalSocket.swift @@ -0,0 +1,103 @@ +// +// PostgreSQLLocalSocket.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +// libpq takes the directory as `host` and dials `/.s.PGSQL.`: 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 = [.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 + } +} diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLLocalSocketTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLLocalSocketTests.swift new file mode 100644 index 0000000000..f9b5e0daf4 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLLocalSocketTests.swift @@ -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")) + } + } +} diff --git a/apple/TablePro/Core/Database/ConnectionCredentialResolver.swift b/apple/TablePro/Core/Database/ConnectionCredentialResolver.swift index de277efbec..f84f39e156 100644 --- a/apple/TablePro/Core/Database/ConnectionCredentialResolver.swift +++ b/apple/TablePro/Core/Database/ConnectionCredentialResolver.swift @@ -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 @@ -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. diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index 1e280d3adc..3f8dc4a5df 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -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]) } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index d6a1f4d05f..4a14f2c2cc 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -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( diff --git a/apple/TablePro/Core/Services/Export/ConnectionShareLink.swift b/apple/TablePro/Core/Services/Export/ConnectionShareLink.swift index 38efed2e9c..dd1ffde095 100644 --- a/apple/TablePro/Core/Services/Export/ConnectionShareLink.swift +++ b/apple/TablePro/Core/Services/Export/ConnectionShareLink.swift @@ -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))) diff --git a/apple/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift b/apple/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift index abccd19e44..cd2fe4678a 100644 --- a/apple/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift +++ b/apple/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift @@ -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) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/apple/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index aa41ac5884..236defed2d 100644 --- a/apple/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/apple/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -319,13 +319,16 @@ internal enum DeeplinkParser { MySQLLocalSocket.fieldKey: value("socket") ?? fields[MySQLLocalSocket.fieldKey] ?? "" ]) fields.removeValue(forKey: MySQLLocalSocket.fieldKey) + var port = value("port").flatMap(Int.init) ?? dbType.defaultPort var socketPath: String? if let requestedSocket, dbType.supportsLocalSocket, value("ssh") != "1" { - guard MySQLLocalSocket.issue(for: requestedSocket) == nil else { + let socket = LocalSocketEndpoint(path: requestedSocket, port: port, type: dbType) + guard socket.issue == nil else { return .failure(.invalidParameter("socket")) } - socketPath = requestedSocket - fields[MySQLLocalSocket.fieldKey] = requestedSocket + socketPath = socket.path + port = socket.port + fields[MySQLLocalSocket.fieldKey] = socket.path } let additionalFields: [String: String]? = fields.isEmpty ? nil : fields @@ -333,8 +336,6 @@ internal enum DeeplinkParser { return .failure(.missingRequiredParam("host")) } let host = givenHost.isEmpty ? "localhost" : givenHost - - let port = value("port").flatMap(Int.init) ?? dbType.defaultPort let username = value("username") ?? "" let database = value("database") ?? "" diff --git a/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift b/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift index 97d4386e6f..80557e95c4 100644 --- a/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift +++ b/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift @@ -93,7 +93,7 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { private static func details(for connection: DatabaseConnection) -> [String] { var details: [String] = [ - connection.localSocketPath.map { String(format: String(localized: "Socket: %@"), $0) } + connection.localSocketFile.map { String(format: String(localized: "Socket: %@"), $0) } ?? String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") ] if !connection.username.isEmpty { diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 06f97fccac..f15adf5280 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -153,9 +153,13 @@ struct ConnectionURLFormatter { result += "@" } - let socketPath = connection.localSocketPath - if socketPath != nil { + let socket = connection.localSocketEndpoint + let socketIsDirectory = connection.type.localSocketIsDirectory + if let socket { result += "localhost" + if socketIsDirectory, socket.port != defaultPort { + result += ":\(socket.port)" + } } else if connection.type.pluginTypeId == "MongoDB", let mongoHosts = connection.additionalFields["mongoHosts"], !mongoHosts.isEmpty { result += mongoHosts @@ -177,8 +181,12 @@ struct ConnectionURLFormatter { result += "/\(pathComponent.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? pathComponent)" var params: [String] = [] - if let socketPath { - params.append("socket=\(percentEncodeSocketPath(socketPath))") + if let socket { + params.append( + socketIsDirectory + ? "host=\(percentEncodeSocketDirectory(socket.path))" + : "socket=\(percentEncodeSocketPath(socket.path))" + ) } let query = buildQueryString(connection) if !query.isEmpty { @@ -201,6 +209,11 @@ struct ConnectionURLFormatter { path.addingPercentEncoding(withAllowedCharacters: socketPathAllowed) ?? path } + // libpq's documented form, `?host=/var/run/postgresql`: a query keeps `/` as it is. + private static func percentEncodeSocketDirectory(_ path: String) -> String { + path.addingPercentEncoding(withAllowedCharacters: socketPathAllowed.union(CharacterSet(charactersIn: "/"))) ?? path + } + // Trino and Presto read the path as /catalog/schema, as ConnectionURLParser does. private static func catalogSchemaSuffix(for connection: DatabaseConnection, path: String) -> String { guard connection.type.pluginTypeId == DatabaseType.trino.rawValue, !path.isEmpty, @@ -306,6 +319,7 @@ struct ConnectionURLFormatter { return EtcdTLSModeField.sslMode(forFieldValue: connection.additionalFields[EtcdTLSModeField.fieldId]) .map(sslModeParamValue) } + guard !connection.localSocketSkipsTLS else { return nil } let mode = connection.sslConfig.mode let portImpliesTLS = connection.type.impliedSSLMode(forPort: connection.port) != nil guard mode != connection.type.defaultSSLMode || portImpliesTLS else { return nil } diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 3d298dfb32..ef1d1565e0 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -47,6 +47,10 @@ struct ParsedConnectionURL { MySQLLocalSocket.path(in: additionalFields) } + var localSocketFile: String? { + localSocketPath.map { LocalSocketEndpoint(path: $0, port: resolvedPort, type: type).socketFile } + } + var suggestedName: String { if let connectionName, !connectionName.isEmpty { return connectionName @@ -71,6 +75,7 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { case missingHost case invalidRedisDatabaseIndex(String) case invalidSocketPath(MySQLLocalSocket.PathIssue) + case invalidPostgreSQLSocketPath(PostgreSQLLocalSocket.PathIssue) var errorDescription: String? { switch self { @@ -86,6 +91,8 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { return String(format: String(localized: "%@ is not a Redis database index."), path) case .invalidSocketPath(let issue): return issue.message + case .invalidPostgreSQLSocketPath(let issue): + return issue.message } } } @@ -180,7 +187,7 @@ struct ConnectionURLParser { return .failure(.invalidURL) } - let endpoint: (host: String, socketFields: [String: String]) + let endpoint: Endpoint switch resolveEndpoint(components, type: dbType) { case .success(let resolved): endpoint = resolved case .failure(let error): return .failure(error) @@ -188,7 +195,7 @@ struct ConnectionURLParser { var ext = parseQueryItems(components.queryItems, dbType: dbType) - let rawPort = components.port + let rawPort = endpoint.port ?? components.port var sslMode = ext.resolvedSSLMode(for: dbType, port: rawPort ?? dbType.defaultPort) let port = significantPort(rawPort, for: dbType, sslMode: sslMode) let username = components.percentEncodedUser.flatMap { @@ -291,19 +298,69 @@ struct ConnectionURLParser { return (decode(String(path[.. Result<(host: String, socketFields: [String: String]), ConnectionURLParseError> { - localSocketPath(in: components, type: type).flatMap { socketPath in + ) -> Result { + if type.localSocketIsDirectory { + return libpqEndpoint(in: components, type: type) + } + return localSocketPath(in: components, type: type).flatMap { socketPath in if let socketPath { - return .success(("localhost", [MySQLLocalSocket.fieldKey: socketPath])) + return .success(("localhost", [MySQLLocalSocket.fieldKey: socketPath], nil)) } guard let host = components.host, !host.isEmpty else { return .failure(.missingHost) } - return .success((host, [:])) + return .success((host, [:], nil)) + } + } + + // libpq's rules: query `host` and `port` win, and a host starting with `/` is the socket directory. + private static func libpqEndpoint( + in components: URLComponents, + type: DatabaseType + ) -> Result { + let items = components.percentEncodedQueryItems ?? [] + // nil when absent; an empty value is present and means libpq's default. + func queryValue(_ name: String) -> String?? { + guard let item = items.last(where: { $0.name.lowercased() == name }) else { return nil } + return .some((item.value ?? "").removingPercentEncoding?.trimmingCharacters(in: .whitespaces)) + } + let port: Int? + switch queryValue("port") { + case .none: + port = components.port + case .some(.none): + return .failure(.invalidURL) + case .some(.some(let raw)): + guard !raw.isEmpty else { + port = nil + break + } + guard let value = Int(raw), (1...65_535).contains(value) else { return .failure(.invalidURL) } + port = value + } + let host: String + switch queryValue("host") { + case .none: + host = components.host ?? "" + case .some(.none): + return .failure(.invalidURL) + case .some(.some(let raw)): + host = raw + } + guard host.hasPrefix("/") else { + guard !host.isEmpty else { return .failure(.missingHost) } + return .success((host, [:], port ?? type.defaultPort)) + } + let socket = PostgreSQLLocalSocket.endpoint(path: host, port: port ?? type.defaultPort) + if let issue = PostgreSQLLocalSocket.issue(for: socket) { + return .failure(.invalidPostgreSQLSocketPath(issue)) } + return .success(("localhost", [MySQLLocalSocket.fieldKey: socket.directory], socket.port)) } private static let socketParameterNames = ["socket", "unix_socket"] diff --git a/apple/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift b/apple/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift index cf22903f88..415bc37b6f 100644 --- a/apple/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift +++ b/apple/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift @@ -39,7 +39,7 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { database: connection.database, username: connection.username, scopeName: scopeName ?? "", - socketPath: connection.localSocketPath + socketPath: connection.localSocketFile ) } diff --git a/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift b/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift index 34b8c4a968..d89eddbee6 100644 --- a/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift +++ b/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift @@ -93,10 +93,13 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable { /// A file-based driver reaches its database through a path, not a host and a port, and it is /// exactly the driver that shows this picker in order to offer Remote Database File. - static func directSummary(isFileBased: Bool, usesLocalSocket: Bool) -> String { + static func directSummary(isFileBased: Bool, usesLocalSocket: Bool, localSocketSkipsTLS: Bool) -> String { if isFileBased { return String(localized: "Opens the database file on this Mac.") } + if localSocketSkipsTLS { + return String(localized: "Connects through the socket on the General tab. PostgreSQL sends no SSL over a socket, which stays on this Mac.") + } if usesLocalSocket { return String(localized: "Connects through the socket file on the General tab.") } diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift b/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift index d4d5b65667..6eca896f21 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift @@ -29,7 +29,7 @@ extension DatabaseConnection { if let socketPath = sshForwardUnixSocketPath, resolvedSSHConfig.enabled { return (socketPath as NSString).abbreviatingWithTildeInPath } - if let socketPath = localSocketPath { + if let socketPath = localSocketFile { return (socketPath as NSString).abbreviatingWithTildeInPath } if let hostList = hostListDescription { diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+IAMSignIn.swift b/apple/TablePro/Models/Connection/DatabaseConnection+IAMSignIn.swift index 1c3db4a709..0b5a347565 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+IAMSignIn.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+IAMSignIn.swift @@ -59,6 +59,7 @@ extension DatabaseConnection { /// never sent in plaintext: TLS is raised to Required. The Cloud SQL Auth Proxy is the exception, /// because its loopback listener has no TLS and the proxy encrypts the hop to the instance. var transportSSLConfiguration: SSLConfiguration { + guard !localSocketSkipsTLS else { return SSLConfiguration() } var ssl = sslConfig guard usesIAMToken, !tunnelSecuresTransport, ssl.mode == .disabled || ssl.mode == .preferred else { return ssl diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift b/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift index 8dd69a241b..4d0339f2f1 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift @@ -10,6 +10,34 @@ extension DatabaseType { var supportsLocalSocket: Bool { PluginMetadataRegistry.shared.snapshot(for: self)?.connection.defaultLocalSocketPath != nil } + + // libpq names the socket file after the port, so the path is a directory and Port stays. + var localSocketIsDirectory: Bool { + supportsLocalSocket && pluginTypeId == DatabaseType.postgresql.pluginTypeId + } +} + +// MySQL dials the path itself, PostgreSQL `/.s.PGSQL.`, so `path` is that directory. +struct LocalSocketEndpoint: Equatable { + let path: String + let port: Int + let socketFile: String + let issue: String? + + init(path: String, port: Int, type: DatabaseType) { + guard type.localSocketIsDirectory else { + self.path = path + self.port = port + socketFile = path + issue = MySQLLocalSocket.issue(for: path)?.message + return + } + let endpoint = PostgreSQLLocalSocket.endpoint(path: path, port: port) + self.path = endpoint.directory + self.port = endpoint.port + socketFile = endpoint.socketFile + issue = PostgreSQLLocalSocket.issue(for: endpoint)?.message + } } extension DatabaseConnection { @@ -33,7 +61,21 @@ extension DatabaseConnection { } } + var localSocketEndpoint: LocalSocketEndpoint? { + localSocketPath.map { LocalSocketEndpoint(path: $0, port: port, type: type) } + } + + // The file connect(2) opens, which is what tells two local servers apart. + var localSocketFile: String? { + localSocketEndpoint?.socketFile + } + + // libpq never negotiates TLS on a Unix socket, whatever sslmode says. + var localSocketSkipsTLS: Bool { + localSocketPath != nil && type.localSocketIsDirectory + } + var timeoutEndpointName: String { - localSocketPath ?? host.nilIfEmpty ?? name + localSocketFile ?? host.nilIfEmpty ?? name } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index f3857e28a8..d083a59f85 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -3862,6 +3862,9 @@ } } } + }, + "%@ is not a socket." : { + }, "%@ is not a Redis database index." : { "extractionState" : "manual", @@ -48190,6 +48193,12 @@ }, "Connects through the socket file on the General tab." : { + }, + "Connects to %@. The port names the socket file in the folder." : { + + }, + "Connects through the socket on the General tab. PostgreSQL sends no SSL over a socket, which stays on this Mac." : { + }, "Consistency" : { "extractionState" : "manual", @@ -120887,6 +120896,9 @@ } } } + }, + "Nothing is at %@. Start the server, or check the folder and the port." : { + }, "Nothing is at this path. Start the server, or check the path." : { @@ -183562,6 +183574,9 @@ } } } + }, + "The folder with the server's socket, or the socket file. SHOW unix_socket_directories in psql lists the folders." : { + }, "The following %d queries may permanently modify or delete data. This action cannot be undone.\n\n%@" : { "extractionState" : "stale", @@ -187585,6 +187600,10 @@ } } }, + "The socket path cannot contain a comma." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The socket path contains a control or invisible character." : { "extractionState" : "manual", "generatesSymbol" : false diff --git a/apple/TablePro/Views/Components/SuggestionComboBox.swift b/apple/TablePro/Views/Components/SuggestionComboBox.swift new file mode 100644 index 0000000000..3175e3d9b3 --- /dev/null +++ b/apple/TablePro/Views/Components/SuggestionComboBox.swift @@ -0,0 +1,73 @@ +// +// SuggestionComboBox.swift +// TablePro +// + +import AppKit +import SwiftUI + +// AppKit on purpose: SwiftUI has no editable field with a suggestion list before macOS 15. +struct SuggestionComboBox: NSViewRepresentable { + let suggestions: @MainActor () -> [String] + var placeholder = "" + var controlSize: NSControl.ControlSize = .regular + var accessibilityLabel: String? + var accessibilityIdentifier: String? + @Binding var value: String + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + func makeNSView(context: Context) -> NSComboBox { + let comboBox = NSComboBox() + comboBox.isEditable = true + comboBox.completes = true + comboBox.usesDataSource = false + comboBox.controlSize = controlSize + comboBox.font = .systemFont(ofSize: NSFont.systemFontSize(for: controlSize)) + if !placeholder.isEmpty { + comboBox.placeholderString = placeholder + } + if let accessibilityLabel { + comboBox.setAccessibilityLabel(accessibilityLabel) + } + if let accessibilityIdentifier { + comboBox.setAccessibilityIdentifier(accessibilityIdentifier) + } + comboBox.addItems(withObjectValues: suggestions()) + comboBox.stringValue = value + comboBox.delegate = context.coordinator + comboBox.setContentHuggingPriority(.defaultLow, for: .horizontal) + return comboBox + } + + func updateNSView(_ comboBox: NSComboBox, context: Context) { + context.coordinator.value = $value + if comboBox.stringValue != value { + comboBox.stringValue = value + } + } + + final class Coordinator: NSObject, NSComboBoxDelegate { + var value: Binding + + init(value: Binding) { + self.value = value + } + + func controlTextDidChange(_ notification: Notification) { + guard let comboBox = notification.object as? NSComboBox else { return } + value.wrappedValue = comboBox.stringValue + } + + func comboBoxSelectionDidChange(_ notification: Notification) { + guard let comboBox = notification.object as? NSComboBox else { return } + DispatchQueue.main.async { + if let selected = comboBox.objectValueOfSelectedItem as? String { + self.value.wrappedValue = selected + } + } + } + } +} diff --git a/apple/TablePro/Views/Connection/AWSProfileField.swift b/apple/TablePro/Views/Connection/AWSProfileField.swift index ae2bd2e814..14d2b074b8 100644 --- a/apple/TablePro/Views/Connection/AWSProfileField.swift +++ b/apple/TablePro/Views/Connection/AWSProfileField.swift @@ -1,41 +1,19 @@ -import AppKit import SwiftUI import TableProPluginKit -struct AWSProfileField: NSViewRepresentable { +struct AWSProfileField: View { let placeholder: String var accessibilityIdentifier: String? @Binding var value: String - func makeCoordinator() -> Coordinator { - Coordinator(value: $value) - } - - func makeNSView(context: Context) -> NSComboBox { - let comboBox = NSComboBox() - comboBox.isEditable = true - comboBox.completes = true - comboBox.usesDataSource = false - comboBox.controlSize = .small - comboBox.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) - if !placeholder.isEmpty { - comboBox.placeholderString = placeholder - } - if let accessibilityIdentifier { - comboBox.setAccessibilityIdentifier(accessibilityIdentifier) - } - comboBox.addItems(withObjectValues: Self.discoveredProfiles()) - comboBox.stringValue = value - comboBox.delegate = context.coordinator - comboBox.setContentHuggingPriority(.defaultLow, for: .horizontal) - return comboBox - } - - func updateNSView(_ comboBox: NSComboBox, context: Context) { - context.coordinator.value = $value - if comboBox.stringValue != value { - comboBox.stringValue = value - } + var body: some View { + SuggestionComboBox( + suggestions: Self.discoveredProfiles, + placeholder: placeholder, + controlSize: .small, + accessibilityIdentifier: accessibilityIdentifier, + value: $value + ) } private static func discoveredProfiles() -> [String] { @@ -44,26 +22,4 @@ struct AWSProfileField: NSViewRepresentable { credentialsContents: AWSConfigFile.readFile(AWSConfigFile.defaultCredentialsPath) ) } - - final class Coordinator: NSObject, NSComboBoxDelegate { - var value: Binding - - init(value: Binding) { - self.value = value - } - - func controlTextDidChange(_ notification: Notification) { - guard let comboBox = notification.object as? NSComboBox else { return } - value.wrappedValue = comboBox.stringValue - } - - func comboBoxSelectionDidChange(_ notification: Notification) { - guard let comboBox = notification.object as? NSComboBox else { return } - DispatchQueue.main.async { - if let selected = comboBox.objectValueOfSelectedItem as? String { - self.value.wrappedValue = selected - } - } - } - } } diff --git a/apple/TablePro/Views/Connection/DeeplinkImportSheet.swift b/apple/TablePro/Views/Connection/DeeplinkImportSheet.swift index a900a07544..2319fb63ec 100644 --- a/apple/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/apple/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -129,10 +129,11 @@ struct DeeplinkImportSheet: View { } private static func socketPath(_ connection: ExportableConnection) -> String? { - guard connection.sshConfig == nil, DatabaseType(rawValue: connection.type).supportsLocalSocket else { - return nil - } - return MySQLLocalSocket.path(in: connection.additionalFields ?? [:]) + let type = DatabaseType(rawValue: connection.type) + guard connection.sshConfig == nil, type.supportsLocalSocket, + let path = MySQLLocalSocket.path(in: connection.additionalFields ?? [:]) + else { return nil } + return LocalSocketEndpoint(path: path, port: connection.port, type: type).socketFile } private static func hostDisplay(_ connection: ExportableConnection) -> String { diff --git a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift index 3dab2e5ab1..9c2fae37f6 100644 --- a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift +++ b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift @@ -99,6 +99,10 @@ extension ConnectionFormCoordinator { services.pluginManager.supportsSSL(for: network.type) } + var showsSSL: Bool { + supportsSSL && !network.localSocketSkipsTLS + } + internal var tunnelCommandPreview: String? { let endpoint = buildEdits().applied(to: DatabaseConnection(name: "")).tunnelForwardEndpoint return TunnelCommandBuilder.previewCommand( diff --git a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index dd9c2d1f27..00511239de 100644 --- a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -305,7 +305,7 @@ final class ConnectionFormCoordinator: ObservableObject { advanced.write(into: &fields) var resolvedHost = network.resolvedHost - var resolvedPort = network.resolvedPort + var resolvedPort = network.resolvedLocalSocketEndpoint?.port ?? network.resolvedPort // An empty list means what its placeholder shows, never the Host it hid. if let hostList = PluginManager.shared.additionalConnectionFields(for: network.type).endpointHostList { diff --git a/apple/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/apple/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 2f1957f2c4..b22d84f4b6 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -33,6 +33,9 @@ struct GeneralPaneView: View { } .formStyle(.grouped) .defaultFocus($nameFocused, true) + .task(id: coordinator.auth.pgpassLookup) { + coordinator.auth.updatePgpassStatus() + } } // MARK: - Identity @@ -99,6 +102,9 @@ struct GeneralPaneView: View { } if coordinator.network.usesLocalSocket { LocalSocketPathField(coordinator: coordinator) + if coordinator.network.localSocketIsDirectory { + portField + } } else { hostFieldsView } @@ -113,7 +119,11 @@ struct GeneralPaneView: View { Text(String(localized: "Connection")) } footer: { if coordinator.network.usesLocalSocket { - LocalSocketFooter(status: coordinator.network.localSocketFileStatus) + LocalSocketFooter( + status: coordinator.network.localSocketFileStatus, + socketFile: coordinator.network.resolvedLocalSocketEndpoint?.socketFile, + isDirectory: coordinator.network.localSocketIsDirectory + ) } else if usesForwardSocket { Text(String(localized: """ Host and Port are unused. The SSH tunnel forwards to the socket path set \ @@ -157,13 +167,8 @@ struct GeneralPaneView: View { ) .accessibilityIdentifier("connection-form-host") .disabled(usesForwardSocket) - TextField( - String(localized: "Port"), - text: portBinding, - prompt: Text(defaultPortString) - ) - .accessibilityIdentifier("connection-form-port") - .disabled(usesForwardSocket) + portField + .disabled(usesForwardSocket) portTLSNotice } ForEach(connectionFields, id: \.id) { field in @@ -176,6 +181,15 @@ struct GeneralPaneView: View { } } + private var portField: some View { + TextField( + String(localized: "Port"), + text: portBinding, + prompt: Text(defaultPortString) + ) + .accessibilityIdentifier("connection-form-port") + } + private var usesForwardSocket: Bool { coordinator.ssh.state.enabled && coordinator.network.forwardsToUnixSocket } diff --git a/apple/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift b/apple/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift index 84b436dbd9..d295022658 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift @@ -28,17 +28,32 @@ struct LocalSocketPathField: View { @ObservedObject var coordinator: ConnectionFormCoordinator var body: some View { - TextField( - String(localized: "Socket"), - text: $coordinator.network.localSocketPath, - prompt: Text(coordinator.network.localSocketPrompt) - ) - .accessibilityIdentifier("connection-form-socket-path") + let suggestions = coordinator.network.localSocketSuggestions + if suggestions.isEmpty { + TextField( + String(localized: "Socket"), + text: $coordinator.network.localSocketPath, + prompt: Text(coordinator.network.localSocketPrompt) + ) + .accessibilityIdentifier("connection-form-socket-path") + } else { + LabeledContent(String(localized: "Socket")) { + SuggestionComboBox( + suggestions: { suggestions }, + placeholder: coordinator.network.localSocketPrompt, + accessibilityLabel: String(localized: "Socket"), + accessibilityIdentifier: "connection-form-socket-path", + value: $coordinator.network.localSocketPath + ) + } + } } } struct LocalSocketFooter: View { let status: LocalSocketFileStatus? + var socketFile: String? + var isDirectory = false var body: some View { Text(message) @@ -48,6 +63,10 @@ struct LocalSocketFooter: View { } private var message: String { + isDirectory ? directoryMessage : fileMessage + } + + private var fileMessage: String { switch status { case .missing: return String(localized: "Nothing is at this path. Start the server, or check the path.") @@ -57,4 +76,28 @@ struct LocalSocketFooter: View { return String(localized: "The server's socket file on this Mac. SELECT @@socket in the mysql client shows it.") } } + + private var directoryMessage: String { + guard let socketFile else { + return String(localized: """ + The folder with the server's socket, or the socket file. SHOW unix_socket_directories \ + in psql lists the folders. + """) + } + let file = (socketFile as NSString).abbreviatingWithTildeInPath + switch status { + case .missing: + return String( + format: String(localized: "Nothing is at %@. Start the server, or check the folder and the port."), + file + ) + case .notSocket: + return String(format: String(localized: "%@ is not a socket."), file) + case .socket, .none: + return String( + format: String(localized: "Connects to %@. The port names the socket file in the folder."), + file + ) + } + } } diff --git a/apple/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift b/apple/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift index 34653bd4d6..edf48ad3b6 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift @@ -19,7 +19,7 @@ struct NetworkPaneView: View { Form { transportPicker transportSections - if coordinator.supportsSSL { + if coordinator.showsSSL { SSLSections( databaseType: coordinator.network.type, usesLocalSocket: coordinator.network.usesLocalSocket, @@ -60,7 +60,8 @@ struct NetworkPaneView: View { private var directSummary: String { ConnectionTunnelKind.directSummary( isFileBased: coordinator.network.connectionMode == .fileBased, - usesLocalSocket: coordinator.network.usesLocalSocket + usesLocalSocket: coordinator.network.usesLocalSocket, + localSocketSkipsTLS: coordinator.network.localSocketSkipsTLS ) } diff --git a/apple/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/apple/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index 57bac571d8..585f78ace6 100644 --- a/apple/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/apple/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -223,17 +223,27 @@ final class AuthPaneViewModel: ObservableObject { } } + var pgpassLookup: PgpassLookup? { + guard usePgpass, let network = coordinator?.value?.network else { return nil } + let endpoint = network.pgpassEndpoint + return PgpassLookup( + host: endpoint.host, + port: endpoint.port, + database: network.database, + username: PgpassReader.effectiveUsername(resolvedUsername) + ) + } + func updatePgpassStatus() { - guard let coordinator = coordinator?.value else { return } - guard usePgpass else { + guard let lookup = pgpassLookup else { pgpassStatus = .notChecked return } pgpassStatus = PgpassStatus.check( - host: coordinator.network.resolvedHost, - port: coordinator.network.resolvedPort, - database: coordinator.network.database, - username: PgpassReader.effectiveUsername(resolvedUsername) + host: lookup.host, + port: lookup.port, + database: lookup.database, + username: lookup.username ) } } diff --git a/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift b/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift index 6523466dbb..0bfda7636f 100644 --- a/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift +++ b/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift @@ -20,10 +20,22 @@ extension NetworkPaneViewModel { supportsLocalSocket && endpoint == .localSocket } + var localSocketIsDirectory: Bool { + type.localSocketIsDirectory + } + + var localSocketSkipsTLS: Bool { + usesLocalSocket && localSocketIsDirectory + } + var localSocketPrompt: String { PluginManager.shared.defaultLocalSocketPath(for: type) ?? MySQLLocalSocket.defaultPath } + var localSocketSuggestions: [String] { + localSocketIsDirectory ? PostgreSQLLocalSocket.commonDirectories : [] + } + // The driver hands the path to connect(2) unchanged, so `~` is expanded here. var resolvedLocalSocketPath: String? { let trimmed = localSocketPath.trimmingCharacters(in: .whitespacesAndNewlines) @@ -31,22 +43,30 @@ extension NetworkPaneViewModel { return (trimmed as NSString).expandingTildeInPath } + var resolvedLocalSocketEndpoint: LocalSocketEndpoint? { + guard usesLocalSocket, let path = resolvedLocalSocketPath else { return nil } + return LocalSocketEndpoint(path: path, port: resolvedPort, type: type) + } + + var pgpassEndpoint: (host: String, port: Int) { + if localSocketIsDirectory, let socket = resolvedLocalSocketEndpoint { + return (PostgreSQLLocalSocket.pgpassHost(forHost: socket.path), socket.port) + } + return (PostgreSQLLocalSocket.pgpassHost(forHost: resolvedHost), resolvedPort) + } + var localSocketIssues: [String] { guard usesLocalSocket else { return [] } - guard let path = resolvedLocalSocketPath else { + guard let endpoint = resolvedLocalSocketEndpoint else { return [String(format: String(localized: "%@ is required"), String(localized: "Socket"))] } - guard let issue = MySQLLocalSocket.issue(for: path) else { return [] } - return [issue.message] + return endpoint.issue.map { [$0] } ?? [] } // Advisory only: the server may start after the connection is saved. var localSocketFileStatus: LocalSocketFileStatus? { - guard usesLocalSocket, - let path = resolvedLocalSocketPath, - MySQLLocalSocket.issue(for: path) == nil - else { return nil } - return Self.localSocketFileStatus(atPath: path) + guard let endpoint = resolvedLocalSocketEndpoint, endpoint.issue == nil else { return nil } + return Self.localSocketFileStatus(atPath: endpoint.socketFile) } // stat, not attributesOfItem, which reports a symlink to the socket as a symlink. diff --git a/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index 73f9911e76..0e2e6e0084 100644 --- a/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/apple/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -254,8 +254,8 @@ final class NetworkPaneViewModel: ObservableObject { if !socketPath.isEmpty { fields[DatabaseConnection.sshForwardUnixSocketPathKey] = socketPath } - if usesLocalSocket, let localSocket = resolvedLocalSocketPath { - fields[MySQLLocalSocket.fieldKey] = localSocket + if let localSocket = resolvedLocalSocketEndpoint { + fields[MySQLLocalSocket.fieldKey] = localSocket.path } } } diff --git a/apple/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift b/apple/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift index 17ba6f5a73..b13af4253b 100644 --- a/apple/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift +++ b/apple/TablePro/Views/ConnectionForm/ViewModels/SSLPaneViewModel.swift @@ -21,7 +21,7 @@ final class SSLPaneViewModel: ObservableObject { /// Silent on a driver that renders no SSL section, so a stored mode the form cannot show /// cannot disable Save over a certificate field the user has no way to reach. var validationIssues: [String] { - guard coordinator?.value?.supportsSSL ?? true else { return [] } + guard coordinator?.value?.showsSSL ?? true else { return [] } let type = coordinator?.value?.network.type guard type?.supportsPerConnectionCertificatePaths ?? true else { return [] } var issues: [String] = [] diff --git a/apple/TableProMobile/TableProMobile/Helpers/DatabaseConnection+MacSocket.swift b/apple/TableProMobile/TableProMobile/Helpers/DatabaseConnection+MacSocket.swift index 1a18650734..9b4efaa2c4 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/DatabaseConnection+MacSocket.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/DatabaseConnection+MacSocket.swift @@ -5,7 +5,7 @@ nonisolated extension DatabaseConnection { /// A tunnel replaces the socket on the Mac too, so only a direct connection would dial it. var macSocketPath: String? { switch type { - case .mysql, .mariadb, .tidb, .oceanbase: + case .mysql, .mariadb, .tidb, .oceanbase, .postgresql: guard !(sshEnabled && sshConfiguration != nil) else { return nil } return MySQLLocalSocket.path(in: additionalFields) default: diff --git a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 93a040823a..e2e8c68d54 100644 --- a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -127,6 +127,9 @@ nonisolated final class IOSDriverFactory: DriverFactory { signsInWithToken: connection.iamSignIn != nil ) case .postgresql, .redshift: + guard connection.macSocketPath == nil else { + throw ConnectionError.localSocketNotSupported + } return PostgreSQLDriver( host: connection.host, port: connection.port, diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift index b4c562bfc9..7c1c9e1ab3 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift @@ -52,7 +52,7 @@ struct ConnectionDetailFormatterTests { @Test( "A connection that dials a socket on the Mac names the socket, not a host and port", - arguments: [DatabaseType.mysql, .mariadb, .tidb, .oceanbase] + arguments: [DatabaseType.mysql, .mariadb, .tidb, .oceanbase, .postgresql] ) func macSocket(type: DatabaseType) { let withDatabase = DatabaseConnection( @@ -97,14 +97,14 @@ struct ConnectionDetailFormatterTests { port: 3_307, additionalFields: [MySQLLocalSocket.fieldKey: " "] ) - let postgres = DatabaseConnection( - type: .postgresql, + let redshift = DatabaseConnection( + type: .redshift, host: "db.acme.io", - port: 5_432, + port: 5_439, additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"] ) #expect(ConnectionDetailFormatter.detail(for: blank) == "db.acme.io:3307") - #expect(ConnectionDetailFormatter.detail(for: postgres) == "db.acme.io") + #expect(ConnectionDetailFormatter.detail(for: redshift) == "db.acme.io") } } diff --git a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift index 3ca262cef3..442db81c28 100644 --- a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift @@ -37,6 +37,20 @@ struct IOSDriverFactoryRefusalTests { #expect(IOSDriverFactory.refusal(for: socketConnection(sshHost: "bastion.acme.io")) == nil) } + @Test("A PostgreSQL connection on a Mac socket is refused instead of dialing its leftover host") + func postgresMacSocketIsRefused() { + let connection = DatabaseConnection( + type: .postgresql, + host: "localhost", + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp"] + ) + + #expect(IOSDriverFactory.refusal(for: connection) == .localSocketNotSupported) + #expect(throws: ConnectionError.localSocketNotSupported) { + try IOSDriverFactory().createDriver(for: connection, password: nil) + } + } + @Test("The connect path gets the device's answer, socket included, not the protocol default") func connectPathUsesTheDeviceRule() { let factory: any DriverFactory = IOSDriverFactory() diff --git a/apple/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift b/apple/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift index ff8e12bccb..d367af3fea 100644 --- a/apple/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift +++ b/apple/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift @@ -48,7 +48,7 @@ struct SocketPathPlaceholderTests { @Test("No other type offers a socket on this Mac") func otherTypesHaveNoLocalSocket() { let unknown = DatabaseType(rawValue: "FuturePlugin") - for type in [DatabaseType.tidb, .databend, .oceanbase, .postgresql, .redis, .sqlite, unknown] { + for type in [DatabaseType.tidb, .databend, .oceanbase, .redshift, .cockroachdb, DatabaseType(rawValue: "PGlite"), .redis, .sqlite, unknown] { #expect(PluginManager.shared.defaultLocalSocketPath(for: type) == nil, "\(type.rawValue)") } } diff --git a/apple/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift b/apple/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift index b304d45113..b07f2b8a15 100644 --- a/apple/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift +++ b/apple/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift @@ -78,8 +78,8 @@ struct ConnectionImportLinkSocketTests { @Test("An engine without a socket mode drops the socket and still needs a host") func otherEngineDropsSocket() throws { let url = try importLink([ - URLQueryItem(name: "type", value: "PostgreSQL"), - URLQueryItem(name: "socket", value: "/tmp/.s.PGSQL.5432") + URLQueryItem(name: "type", value: "Redshift"), + URLQueryItem(name: "socket", value: "/tmp/.s.PGSQL.5439") ]) guard case .failure(let error) = DeeplinkParser.parse(url) else { Issue.record("Expected failure"); return @@ -87,7 +87,7 @@ struct ConnectionImportLinkSocketTests { #expect(error == .missingRequiredParam("host")) let withHost = try imported([ - URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "type", value: "Redshift"), URLQueryItem(name: "host", value: "db.example.com"), URLQueryItem(name: "af_localSocketPath", value: "/tmp/mysql.sock") ]) diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index acf45d25b9..fc73e2675f 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -742,8 +742,8 @@ struct ConnectionURLFormatterTests { @Test("A socket field on an engine without a socket mode is not written") func socketOnOtherEngineIsNotWritten() { - let connection = socketConnection("/tmp/mysql.sock", type: .postgresql, host: "db.example.com", port: 5_432) + let connection = socketConnection("/tmp/mysql.sock", type: .redshift, host: "db.example.com", port: 5_439) let url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) - #expect(url == "postgresql://root@db.example.com/local") + #expect(url == "redshift://root@db.example.com/local") } } diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift index 9db467b712..9a8fc7bc2d 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift @@ -99,7 +99,7 @@ struct ConnectionURLParserSocketTests { @Test( "Engines without a socket mode ignore socket", arguments: [ - "postgresql://u@localhost/db?socket=/tmp/.s.PGSQL.5432", + "redshift://u@localhost/db?socket=/tmp/.s.PGSQL.5439", "tidb://root@localhost/db?socket=/tmp/mysql.sock", "redis://localhost?socket=/tmp/redis.sock" ] diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLPostgreSQLSocketTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLPostgreSQLSocketTests.swift new file mode 100644 index 0000000000..df9210deff --- /dev/null +++ b/apple/TableProTests/Core/Utilities/ConnectionURLPostgreSQLSocketTests.swift @@ -0,0 +1,163 @@ +// +// ConnectionURLPostgreSQLSocketTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct ConnectionURLPostgreSQLSocketTests { + private func parsed(_ url: String) throws -> ParsedConnectionURL { + try ConnectionURLParser.parse(url).get() + } + + private func socketConnection(_ path: String, port: Int = 5_432, sslMode: SSLMode = .preferred) -> DatabaseConnection { + DatabaseConnection( + name: "", host: "db.internal", port: port, database: "app", username: "dat", type: .postgresql, + sslConfig: SSLConfiguration(mode: sslMode), + additionalFields: [MySQLLocalSocket.fieldKey: path] + ) + } + + @Test( + "libpq's socket forms name the directory", + arguments: [ + "postgresql:///app?host=/var/run/postgresql", + "postgresql:///app?host=%2Fvar%2Frun%2Fpostgresql", + "postgresql://%2Fvar%2Frun%2Fpostgresql/app", + "postgres://dat@localhost/app?host=/var/run/postgresql", + "postgresql://dat@db.example.com/app?host=/var/run/postgresql" + ] + ) + func socketForms(_ url: String) throws { + let parsed = try parsed(url) + #expect(parsed.type == .postgresql) + #expect(parsed.localSocketPath == "/var/run/postgresql") + #expect(parsed.host == "localhost") + #expect(parsed.database == "app") + #expect(parsed.resolvedPort == 5_432) + #expect(parsed.localSocketFile == "/var/run/postgresql/.s.PGSQL.5432") + } + + @Test("The port picks the socket file, from the URL, the query or the file name") + func socketPort() throws { + #expect(try parsed("postgresql://dat@:5433/app?host=/tmp").resolvedPort == 5_433) + #expect(try parsed("postgresql://%2Ftmp:5433/app").resolvedPort == 5_433) + #expect(try parsed("postgresql:///app?host=/tmp&port=5434").resolvedPort == 5_434) + let file = try parsed("postgresql:///app?host=/tmp/.s.PGSQL.5435") + #expect(file.localSocketPath == "/tmp") + #expect(file.resolvedPort == 5_435) + } + + @Test("An empty query port means the default port, and a bad one refuses the URL, as libpq does") + func queryPortPresence() throws { + #expect(try parsed("postgresql://dat@localhost:5433/app?host=/tmp&port=").resolvedPort == 5_432) + #expect(try parsed("postgresql://dat@:5433/app?host=db.example.com&port=").resolvedPort == 5_432) + #expect(try parsed("postgresql://dat@db.example.com:5433/app?port=").resolvedPort == 5_432) + #expect(try parsed("postgresql://dat@db.example.com:5433/app").resolvedPort == 5_433) + for url in ["postgresql://dat@localhost:5433/app?host=/tmp&port=bad", "postgresql:///app?host=/tmp&port=70000"] { + guard case .failure(let error) = ConnectionURLParser.parse(url) else { + Issue.record("Expected \(url) to fail"); continue + } + #expect(error == .invalidURL) + } + } + + @Test("An empty query host is not the URL's own host") + func emptyQueryHost() { + guard case .failure(let error) = ConnectionURLParser.parse("postgresql://dat@db.example.com/app?host=") else { + Issue.record("Expected failure"); return + } + #expect(error == .missingHost) + } + + @Test("A query host or port overrides the URL's own, as libpq does") + func queryOverridesAuthority() throws { + let parsed = try parsed("postgresql://dat@a.example.com:5433/app?host=b.example.com&port=6432") + #expect(parsed.host == "b.example.com") + #expect(parsed.resolvedPort == 6_432) + #expect(parsed.localSocketPath == nil) + } + + @Test("A plus in the directory stays a plus, since libpq only percent-decodes") + func plusStaysLiteral() throws { + #expect(try parsed("postgresql:///app?host=/tmp/a+b").localSocketPath == "/tmp/a+b") + #expect(try parsed("postgresql:///app?host=/tmp/a%20b").localSocketPath == "/tmp/a b") + } + + @Test("socket= is MySQL's spelling, which libpq does not read") + func socketParameterIsIgnored() throws { + let parsed = try parsed("postgresql://dat@localhost/app?socket=/tmp") + #expect(parsed.localSocketPath == nil) + #expect(parsed.host == "localhost") + } + + @Test("A directory libpq would misread fails the URL") + func badDirectoryFails() { + let cases: [(String, PostgreSQLLocalSocket.PathIssue)] = [ + ("postgresql:///app?host=/tmp,/var/run/postgresql", .comma), + ("postgresql:///app?host=/tmp%0Ax", .hiddenCharacter) + ] + for (url, issue) in cases { + guard case .failure(let error) = ConnectionURLParser.parse(url) else { + Issue.record("Expected \(url) to fail"); continue + } + #expect(error == .invalidPostgreSQLSocketPath(issue)) + #expect(error.errorDescription == issue.message) + } + } + + @Test("Redshift and CockroachDB keep their host") + func otherPostgresEnginesKeepHost() throws { + #expect(try parsed("redshift://u@cluster.example.com/dev?host=/tmp").localSocketPath == nil) + #expect(try parsed("cockroachdb://u@node.example.com/db?host=/tmp").localSocketPath == nil) + } + + @Test("A socket connection copies as localhost and libpq's host= form") + func copiesAsHostParameter() { + let url = ConnectionURLFormatter.format(socketConnection("/var/run/postgresql"), password: "pw", sshPassword: nil) + #expect(url == "postgresql://dat:pw@localhost/app?host=/var/run/postgresql") + + let otherPort = ConnectionURLFormatter.format(socketConnection("/tmp", port: 5_433), password: nil, sshPassword: nil) + #expect(otherPort == "postgresql://dat@localhost:5433/app?host=/tmp") + } + + @Test("A socket file stored whole copies as its directory and port") + func socketFileCopiesAsDirectory() { + let url = ConnectionURLFormatter.format(socketConnection("/tmp/.s.PGSQL.5433"), password: nil, sshPassword: nil) + #expect(url == "postgresql://dat@localhost:5433/app?host=/tmp") + } + + @Test("sslmode is left out, since no sslmode encrypts a socket") + func noSSLModeOnSocket() { + let url = ConnectionURLFormatter.format(socketConnection("/tmp", sslMode: .verifyIdentity), password: nil, sshPassword: nil) + #expect(!url.contains("sslmode")) + } + + @Test( + "A copied socket URL parses back to the same socket file", + arguments: ["/tmp", "/var/run/postgresql", "/Users/me/Library/Application Support/Postgres/var-17", "/tmp/a+b&c=d"] + ) + func roundTrip(_ directory: String) throws { + for port in [5_432, 5_433] { + let connection = socketConnection(directory, port: port) + let url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) + let parsed = try ConnectionURLParser.parse(url).get() + #expect(parsed.localSocketPath == directory) + #expect(parsed.resolvedPort == port) + #expect(DatabaseURLConnectionMatch.matches(saved: connection, parsed: parsed)) + } + } + + @Test("Two servers in one socket directory are told apart by port") + func matchByPort() throws { + let parsed = try parsed("postgresql://dat@localhost:5433/app?host=/tmp") + #expect(DatabaseURLConnectionMatch.matches(saved: socketConnection("/tmp", port: 5_433), parsed: parsed)) + #expect(!DatabaseURLConnectionMatch.matches(saved: socketConnection("/tmp", port: 5_432), parsed: parsed)) + #expect(!DatabaseURLConnectionMatch.matches(saved: socketConnection("/var/run/postgresql", port: 5_433), parsed: parsed)) + } +} diff --git a/apple/TableProTests/Database/NativeDumpRegistryTests.swift b/apple/TableProTests/Database/NativeDumpRegistryTests.swift index 493cf57217..862b365fc1 100644 --- a/apple/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/apple/TableProTests/Database/NativeDumpRegistryTests.swift @@ -330,6 +330,23 @@ struct NativeDumpRegistryTests { } } + @Test("A PostgreSQL socket goes to -h as its directory, with the port its file is named after", arguments: [ + NativeDumpKind.backup, .restore + ]) + func postgresEndpointFollowsTheSocket(kind: NativeDumpKind) throws { + var socket = connection(type: .postgresql, host: "db.example.com", port: 5_432, sslMode: .verifyIdentity, sslEnabled: true) + socket.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/.s.PGSQL.5433" + let built = try command(.postgresql, kind: kind, connection: socket) + #expect(built.arguments.starts(with: ["--no-password", "-h", "/tmp", "-p", "5433"])) + #expect(!built.arguments.contains("db.example.com")) + #expect(built.environment["PGSSLMODE"] == nil) + + var directory = connection(type: .postgresql, port: 5_434) + directory.additionalFields[MySQLLocalSocket.fieldKey] = "/var/run/postgresql" + let fromDirectory = try command(.postgresql, kind: kind, connection: directory) + #expect(fromDirectory.arguments.starts(with: ["--no-password", "-h", "/var/run/postgresql", "-p", "5434"])) + } + /// Measured, MariaDB 12.3.3 answers any `--ssl-mode` with `unknown variable` and exit 7, and /// MySQL 8.4.11 answers `--ssl` with `unknown option` and exit 2, so neither spelling may reach /// the other family's tool in either direction (#3046). diff --git a/apple/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift b/apple/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift index cbaad5879e..a0b740a8cb 100644 --- a/apple/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift +++ b/apple/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift @@ -27,7 +27,7 @@ struct DatabaseConnectionLocalSocketTests { @Test("A type the form offers no socket for reports none") func otherTypesReportNoSocket() { - for type in [DatabaseType.tidb, .oceanbase, .databend, .postgresql] { + for type in [DatabaseType.tidb, .oceanbase, .databend, .redshift] { #expect(connection(type: type).localSocketPath == nil, "\(type.rawValue)") } } diff --git a/apple/TableProTests/Models/DatabaseConnectionPostgreSQLSocketTests.swift b/apple/TableProTests/Models/DatabaseConnectionPostgreSQLSocketTests.swift new file mode 100644 index 0000000000..470d27fe71 --- /dev/null +++ b/apple/TableProTests/Models/DatabaseConnectionPostgreSQLSocketTests.swift @@ -0,0 +1,123 @@ +// +// DatabaseConnectionPostgreSQLSocketTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProImport +import TableProPluginKit +import TableProSSHConfig +import Testing + +@MainActor +struct DatabaseConnectionPostgreSQLSocketTests { + private func socketConnection( + _ path: String = "/tmp", + port: Int = 5_432, + username: String = "dat", + sslMode: SSLMode = .verifyIdentity + ) -> DatabaseConnection { + var connection = DatabaseConnection( + name: "Local", host: "localhost", port: port, database: "app", username: username, type: .postgresql + ) + connection.sslConfig = SSLConfiguration(mode: sslMode, caCertificatePath: "/etc/ca.pem") + connection.additionalFields[MySQLLocalSocket.fieldKey] = path + return connection + } + + @Test("A PostgreSQL socket is the file its folder and port name") + func socketFile() { + #expect(socketConnection("/var/run/postgresql", port: 5_433).localSocketFile == "/var/run/postgresql/.s.PGSQL.5433") + #expect(socketConnection("/tmp/.s.PGSQL.5434").localSocketFile == "/tmp/.s.PGSQL.5434") + #expect(socketConnection().timeoutEndpointName == "/tmp/.s.PGSQL.5432") + #expect(socketConnection().endpointDescription == "/tmp/.s.PGSQL.5432") + } + + @Test("A MySQL socket stays the path it names") + func mysqlSocketFile() { + var mysql = DatabaseConnection(name: "Local", host: "localhost", port: 3_306, type: .mysql) + mysql.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" + #expect(mysql.localSocketFile == "/tmp/mysql.sock") + #expect(!mysql.localSocketSkipsTLS) + } + + @Test("A socket connection signs in with no SSL, whatever the saved mode") + func noTLSOverSocket() { + let connection = socketConnection() + #expect(connection.localSocketSkipsTLS) + #expect(connection.transportSSLConfiguration == SSLConfiguration()) + + var tunneled = connection + tunneled.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + #expect(!tunneled.localSocketSkipsTLS) + #expect(tunneled.transportSSLConfiguration.mode == .verifyIdentity) + } + + @Test("~/.pgpass is looked up by localhost for /tmp, by the folder otherwise, and with the port") + func pgpassLookup() { + let defaultFolder = ConnectionCredentialResolver.pgpassLookup(for: socketConnection("/tmp", port: 5_433), username: "dat") + #expect(defaultFolder == PgpassLookup(host: "localhost", port: 5_433, database: "app", username: "dat")) + + let otherFolder = ConnectionCredentialResolver.pgpassLookup( + for: socketConnection("/var/run/postgresql/.s.PGSQL.5434"), + username: "dat" + ) + #expect(otherFolder == PgpassLookup(host: "/var/run/postgresql", port: 5_434, database: "app", username: "dat")) + } + + @Test("A blank user is looked up in ~/.pgpass as the login name, as libpq does") + func pgpassBlankUser() { + let lookup = ConnectionCredentialResolver.pgpassLookup(for: socketConnection(username: ""), username: "") + #expect(lookup.username == NSUserName()) + + let tcp = DatabaseConnection(name: "TCP", host: "", port: 5_432, database: "app", type: .postgresql) + #expect(ConnectionCredentialResolver.pgpassLookup(for: tcp, username: "").host == "localhost") + } + + @Test("Trust given to one socket does not cover another server in the same folder") + func trustCoversOneSocketFile() { + let first = ExternalConnectionTrustKey(connection: socketConnection("/tmp", port: 5_432), scopeName: nil) + let second = ExternalConnectionTrustKey(connection: socketConnection("/tmp", port: 5_433), scopeName: nil) + #expect(first != second) + #expect(first.isLoopbackHost) + } + + @Test("A share link carries the folder and the port, and imports back to the same socket") + func shareLinkRoundTrip() throws { + let library = try ImportLibraryFixture() + defer { library.cleanUp() } + let link = try #require( + ConnectionShareLink.deeplink(for: socketConnection("/var/run/postgresql", port: 5_433), exporter: library.exporter) + ) + let items = URLComponents(string: link)?.queryItems ?? [] + #expect(items.first { $0.name == "socket" }?.value == "/var/run/postgresql") + #expect(items.first { $0.name == "port" }?.value == "5433") + + guard case .success(.importConnection(let bundle)) = DeeplinkParser.parse(try #require(URL(string: link))) else { + Issue.record("The link did not import"); return + } + let settings = try #require(bundle.connections.first?.settings) + #expect(settings.additionalFields?[MySQLLocalSocket.fieldKey] == "/var/run/postgresql") + #expect(settings.port == 5_433) + } + + @Test("An import link naming the socket file imports its folder and port") + func importLinkWithSocketFile() throws { + var components = URLComponents() + components.scheme = "tablepro" + components.host = "import" + components.queryItems = [ + URLQueryItem(name: "name", value: "Local"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "socket", value: "/tmp/.s.PGSQL.5433") + ] + guard case .success(.importConnection(let bundle)) = DeeplinkParser.parse(try #require(components.url)) else { + Issue.record("The link did not import"); return + } + let settings = try #require(bundle.connections.first?.settings) + #expect(settings.additionalFields?[MySQLLocalSocket.fieldKey] == "/tmp") + #expect(settings.port == 5_433) + #expect(settings.host == "localhost") + } +} diff --git a/apple/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift b/apple/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift index e953626014..e51893e863 100644 --- a/apple/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift +++ b/apple/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift @@ -109,10 +109,10 @@ struct NetworkPaneLocalSocketTests { #expect(socketViewModel(path: "~/mysql.sock").validationIssues.isEmpty) } - @Test("Changing the type to PostgreSQL drops the socket") + @Test("Changing the type to Redshift drops the socket") func typeChangeDropsSocket() { let viewModel = socketViewModel(path: "") - viewModel.type = .postgresql + viewModel.type = .redshift var fields: [String: String] = [:] viewModel.write(into: &fields) @@ -123,11 +123,12 @@ struct NetworkPaneLocalSocketTests { #expect(viewModel.localSocketIssues.isEmpty) } - @Test("Socket is offered for MySQL and MariaDB only") + @Test("Socket is offered for MySQL, MariaDB and PostgreSQL only") func socketIsOfferedPerType() { let cases: [(type: DatabaseType, offered: Bool)] = [ (.mysql, true), (.mariadb, true), (.tidb, false), (.oceanbase, false), - (.databend, false), (.postgresql, false), (.sqlite, false) + (.databend, false), (.postgresql, true), (.redshift, false), (.cockroachdb, false), + (DatabaseType(rawValue: "PGlite"), false), (.sqlite, false) ] for entry in cases { let viewModel = NetworkPaneViewModel() diff --git a/apple/TableProTests/Views/ConnectionForm/NetworkPanePostgreSQLSocketTests.swift b/apple/TableProTests/Views/ConnectionForm/NetworkPanePostgreSQLSocketTests.swift new file mode 100644 index 0000000000..74a157d7bb --- /dev/null +++ b/apple/TableProTests/Views/ConnectionForm/NetworkPanePostgreSQLSocketTests.swift @@ -0,0 +1,161 @@ +// +// NetworkPanePostgreSQLSocketTests.swift +// TableProTests +// + +import Darwin +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct NetworkPanePostgreSQLSocketTests { + private func socketForm(path: String, port: String = "5432") -> ConnectionFormCoordinator { + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.network.name = "Local" + coordinator.network.type = .postgresql + coordinator.network.port = port + coordinator.selectEndpoint(.localSocket) + coordinator.network.localSocketPath = path + return coordinator + } + + @Test("PostgreSQL offers Socket, prompts /tmp and suggests the common folders") + func offersSocket() { + let network = socketForm(path: "").network + #expect(network.supportsLocalSocket) + #expect(network.localSocketIsDirectory) + #expect(network.localSocketPrompt == "/tmp") + #expect(network.localSocketSuggestions == ["/tmp", "/var/run/postgresql"]) + + let mysql = NetworkPaneViewModel() + mysql.type = .mysql + #expect(!mysql.localSocketIsDirectory) + #expect(mysql.localSocketSuggestions.isEmpty) + } + + @Test("Socket leaves Direct only and drops SSL, but keeps the Network tab") + func socketDropsSSL() { + let coordinator = socketForm(path: "/tmp") + coordinator.ssl.select(.required) + coordinator.ssl.clientCertPath = "/tmp/client.crt" + + #expect(coordinator.availableTransports == [nil]) + #expect(coordinator.supportsSSL) + #expect(!coordinator.showsSSL) + #expect(coordinator.ssl.validationIssues.isEmpty) + #expect(coordinator.visibleTabs.contains(.network)) + #expect(ConnectionFormTab.network.validationIssues(for: coordinator).isEmpty) + + coordinator.selectEndpoint(.hostAndPort) + #expect(coordinator.showsSSL) + #expect(!coordinator.ssl.validationIssues.isEmpty) + } + + @Test("A MySQL socket keeps its SSL settings") + func mysqlSocketKeepsSSL() { + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.network.type = .mysql + coordinator.selectEndpoint(.localSocket) + #expect(coordinator.showsSSL) + } + + @Test("A folder is saved with the port, and the port names the socket file") + func savesDirectoryAndPort() { + let edits = socketForm(path: "/var/run/postgresql/", port: "5434").buildEdits() + #expect(edits.additionalFields[MySQLLocalSocket.fieldKey] == "/var/run/postgresql") + #expect(edits.port == 5_434) + + let connection = edits.applied(to: DatabaseConnection(name: "")) + #expect(connection.localSocketFile == "/var/run/postgresql/.s.PGSQL.5434") + #expect(connection.transportSSLConfiguration.mode == .disabled) + } + + @Test("A socket file is saved as its folder and the port in its name") + func savesSocketFileAsDirectory() { + let edits = socketForm(path: "/tmp/.s.PGSQL.5433", port: "5432").buildEdits() + #expect(edits.additionalFields[MySQLLocalSocket.fieldKey] == "/tmp") + #expect(edits.port == 5_433) + } + + @Test("A relative path, a comma and a socket file over 103 bytes block Save") + func pathIssues() { + #expect(socketForm(path: "").network.localSocketIssues.count == 1) + #expect(socketForm(path: "run/postgresql").network.localSocketIssues == [PostgreSQLLocalSocket.PathIssue.notAbsolute.message]) + #expect(socketForm(path: "/tmp,/var/run").network.localSocketIssues == [PostgreSQLLocalSocket.PathIssue.comma.message]) + + let directory = "/" + String(repeating: "a", count: 88) + #expect(socketForm(path: directory, port: "5432").network.localSocketIssues.isEmpty) + #expect( + socketForm(path: directory, port: "54321").network.localSocketIssues + == [PostgreSQLLocalSocket.PathIssue.tooLong(bytes: 104).message] + ) + } + + @Test("The file status checks the socket file the folder and port name") + func fileStatusChecksTheSocketFile() throws { + let directory = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("tp-\(UUID().uuidString.prefix(8))", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let form = socketForm(path: directory.path, port: "5433") + #expect(form.network.localSocketFileStatus == .missing) + + let descriptor = try bindUnixSocket(at: directory.appendingPathComponent(".s.PGSQL.5433").path) + defer { close(descriptor) } + #expect(form.network.localSocketFileStatus == .socket) + + form.network.port = "5434" + #expect(form.network.localSocketFileStatus == .missing) + } + + @Test("~/.pgpass is matched by localhost for /tmp and by the folder otherwise") + func pgpassEndpoint() { + let defaultFolder = socketForm(path: "/tmp", port: "5433").network.pgpassEndpoint + #expect(defaultFolder.host == "localhost") + #expect(defaultFolder.port == 5_433) + let otherFolder = socketForm(path: "/var/run/postgresql").network.pgpassEndpoint + #expect(otherFolder.host == "/var/run/postgresql") + #expect(otherFolder.port == 5_432) + + let tcp = NetworkPaneViewModel() + tcp.type = .postgresql + tcp.host = "db.example.com" + tcp.port = "6432" + #expect(tcp.pgpassEndpoint.host == "db.example.com") + #expect(tcp.pgpassEndpoint.port == 6_432) + tcp.host = "" + #expect(tcp.pgpassEndpoint.host == "localhost") + } + + @Test("The Network tab names the socket and says no SSL is used") + func directSummary() { + let summary = ConnectionTunnelKind.directSummary(isFileBased: false, usesLocalSocket: true, localSocketSkipsTLS: true) + #expect(summary != ConnectionTunnelKind.directSummary(isFileBased: false, usesLocalSocket: true, localSocketSkipsTLS: false)) + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.EBADF) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + for (index, byte) in bytes.enumerated() where index < buffer.count - 1 { + buffer[index] = byte + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + close(descriptor) + throw POSIXError(.EADDRINUSE) + } + return descriptor + } +} diff --git a/apple/changelog.d/3721.added.md b/apple/changelog.d/3721.added.md new file mode 100644 index 0000000000..1064e20b10 --- /dev/null +++ b/apple/changelog.d/3721.added.md @@ -0,0 +1 @@ +PostgreSQL connections can use Connect Using > Socket to reach a server on this Mac through its Unix socket. diff --git a/apple/project.yml b/apple/project.yml index 90ae04846f..4634f0e171 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -311,6 +311,8 @@ targets: # The MySQL plugin's socket key and path rules, so the app writes and checks the socket path # exactly as the driver reads it. - Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift + # The PostgreSQL plugin's socket directory rules, for the same reason. + - Plugins/PostgreSQLDriverPlugin/PostgreSQLLocalSocket.swift dependencies: - target: TableProPluginKit embed: true diff --git a/apple/scripts/probes/check-postgresql-local-socket.sh b/apple/scripts/probes/check-postgresql-local-socket.sh new file mode 100755 index 0000000000..ef429ce7b0 --- /dev/null +++ b/apple/scripts/probes/check-postgresql-local-socket.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Check the libpq facts PostgreSQLLocalSocket.swift and the ~/.pgpass rule hard-code, against the +# libpq the plugin links and a server that listens on its socket only. +# +# - `host=` dials `/.s.PGSQL.`; the socket file itself as `host` fails. +# - No sslmode encrypts a socket connection, and a missing root certificate does not stop it. +# - A comma in `host` makes two hosts, so a directory with a comma cannot be named. +# - The default socket directory is /tmp, which ~/.pgpass looks up as `localhost`. +# - pg_dump takes the directory with -h. +# +# Usage: +# scripts/probes/check-postgresql-local-socket.sh +# +# Needs initdb, pg_ctl and pg_dump (brew install postgresql@17) and apple/Libs from +# download-libs.sh. Skips (exit 3) without them. Exits non-zero when a fact no longer holds. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +LIBS="$ROOT/Libs" +INCLUDE="$ROOT/Plugins/PostgreSQLDriverPlugin/CLibPQ/include" +PORT=54619 + +for tool in initdb pg_ctl pg_dump; do + command -v "$tool" >/dev/null || { echo "skip: $tool not on PATH" >&2; exit 3; } +done +[ -f "$LIBS/libpq_arm64.a" ] || { echo "skip: run scripts/download-libs.sh" >&2; exit 3; } + +# sun_path holds 103 bytes, so the socket lives in a short directory, not under $TMPDIR. +WORK="$(mktemp -d)" +SOCKET_DIR="$(mktemp -d /tmp/tp-pgsock.XXXX)" +cleanup() { + pg_ctl -D "$WORK/data" -m immediate stop >/dev/null 2>&1 + rm -rf "$WORK" "$SOCKET_DIR" +} +trap cleanup EXIT + +initdb -D "$WORK/data" -U "$(whoami)" --auth-local=peer --auth-host=reject >/dev/null || exit 1 +cat >>"$WORK/data/postgresql.conf" </dev/null || { cat "$WORK/server.log"; exit 1; } + +cat >"$WORK/probe.c" <<'EOF' +#include +#include +#include +#include "libpq-fe.h" + +static int failures = 0; + +static void expect(int ok, const char *fact) { + printf("%s %s\n", ok ? "ok " : "FAIL", fact); + if (!ok) failures++; +} + +static PGconn *open_with(const char *dir, const char *extra) { + char conninfo[512]; + snprintf(conninfo, sizeof conninfo, "host='%s' port=%s dbname=postgres connect_timeout=3 %s", + dir, getenv("PROBE_PORT"), extra); + return PQconnectdb(conninfo); +} + +int main(void) { + const char *dir = getenv("PROBE_DIR"); + char file[256]; + snprintf(file, sizeof file, "%s/.s.PGSQL.%s", dir, getenv("PROBE_PORT")); + + PGconn *conn = open_with(dir, ""); + expect(PQstatus(conn) == CONNECTION_OK, "the directory as host connects (peer, no user)"); + PQfinish(conn); + + conn = open_with(file, ""); + expect(PQstatus(conn) != CONNECTION_OK, "the socket file as host fails"); + PQfinish(conn); + + conn = open_with(dir, "sslmode=verify-full sslrootcert=/nonexistent"); + expect(PQstatus(conn) == CONNECTION_OK && !PQsslInUse(conn), "verify-full over a socket connects without TLS"); + PQfinish(conn); + + char comma[300]; + snprintf(comma, sizeof comma, "%s,/nonexistent", dir); + conn = open_with(comma, ""); + expect(PQstatus(conn) == CONNECTION_OK && strcmp(PQhost(conn), dir) == 0, "a comma splits host into two hosts"); + PQfinish(conn); + + conn = PQconnectStart("dbname=postgres"); + expect(strcmp(PQhost(conn), "/tmp") == 0, "the default socket directory is /tmp"); + PQfinish(conn); + + setenv("PGPASSFILE", getenv("PROBE_PGPASS"), 1); + conn = PQconnectStart("host=/tmp port=5432 dbname=db user=u"); + expect(strcmp(PQpass(conn), "by-localhost") == 0, "~/.pgpass looks /tmp up as localhost"); + PQfinish(conn); + conn = PQconnectStart("host=/var/run/postgresql port=5432 dbname=db user=u"); + expect(strcmp(PQpass(conn), "by-path") == 0, "~/.pgpass looks another directory up by its path"); + PQfinish(conn); + + return failures == 0 ? 0 : 1; +} +EOF + +printf 'localhost:5432:*:u:by-localhost\n/var/run/postgresql:5432:*:u:by-path\n' >"$WORK/pgpass" +chmod 600 "$WORK/pgpass" + +clang -I"$INCLUDE" -o "$WORK/probe" "$WORK/probe.c" \ + "$LIBS/libpq_arm64.a" "$LIBS/libpgcommon_arm64.a" "$LIBS/libpgport_arm64.a" \ + "$LIBS/libssl_arm64.a" "$LIBS/libcrypto_arm64.a" || exit 1 + +status=0 +PROBE_DIR="$SOCKET_DIR" PROBE_PORT="$PORT" PROBE_PGPASS="$WORK/pgpass" "$WORK/probe" || status=1 + +if pg_dump --no-password -h "$SOCKET_DIR" -p "$PORT" -Fc -d postgres -f "$WORK/out.dump"; then + echo "ok pg_dump -h dumps over the socket" +else + echo "FAIL pg_dump -h dumps over the socket" + status=1 +fi + +exit "$status"