From 0d7f2fbc109c6e58c26cf1ad584459af43c7a2dc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 10 Oct 2026 19:53:50 +0700 Subject: [PATCH] feat(connections): accept socket= in database URLs and import links --- CHANGELOG.md | 1 + .../MySQLDriverPlugin/MySQLLocalSocket.swift | 9 + .../Export/ConnectionExportService.swift | 16 +- .../DatabaseURLConnectionMatch.swift | 20 ++ .../Infrastructure/DeeplinkParser.swift | 50 +++-- .../ExternalConnectionPrompting.swift | 3 +- .../Services/Infrastructure/TabRouter.swift | 8 +- .../Connection/ConnectionURLFormatter.swift | 27 ++- .../Connection/ConnectionURLParser.swift | 64 +++++- .../ExternalConnectionTrustKey.swift | 25 ++- .../DatabaseConnection+LocalSocket.swift | 8 +- TablePro/Resources/Localizable.xcstrings | 10 + .../Connection/DeeplinkImportSheet.swift | 41 +++- .../ClipboardConnectionBanner.swift | 5 +- .../Components/ImportFromURLSheet.swift | 4 +- .../ConnectionFormCoordinator.swift | 20 +- .../TableProMobile/Localizable.xcstrings | 3 + .../ConnectionImportLinkSocketTests.swift | 153 ++++++++++++++ .../DatabaseURLConnectionMatchTests.swift | 83 ++++++++ .../ExternalConnectionGateTests.swift | 54 +++++ .../ExternalConnectionTrustStoreTests.swift | 65 ++++++ .../ConnectionURLFormatterTests.swift | 74 ++++++- .../ConnectionURLParserSocketTests.swift | 187 ++++++++++++++++++ .../Plugins/MySQLLocalSocketTests.swift | 14 ++ .../ConnectionFormClipboardTests.swift | 62 ++++++ docs/connections/urls.mdx | 18 +- docs/databases/mariadb.mdx | 3 +- docs/databases/mysql.mdx | 3 +- docs/developers/connection-import.mdx | 7 +- 29 files changed, 970 insertions(+), 67 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift create mode 100644 TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift create mode 100644 TableProTests/Core/Services/Infrastructure/DatabaseURLConnectionMatchTests.swift create mode 100644 TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf547023e..da9df93878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Group, tags and color in the MCP connection list and on the AppleScript connection class. - Pairing links take `state` and `response_mode`, and send a standard `code` to any redirect. - MySQL and MariaDB connections through a local Unix socket. +- `socket` parameter in `mysql://` and `mariadb://` URLs and `tablepro://import` links. - `tablepro://settings` and `tablepro://settings/` links that open Settings on a pane. ### Changed diff --git a/Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift b/Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift index d907c49ed6..954ffac17b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift +++ b/Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift @@ -14,12 +14,15 @@ nonisolated internal enum MySQLLocalSocket { enum PathIssue: Equatable, Sendable { case notAbsolute + case hiddenCharacter 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 .tooLong(let bytes): return String( format: String(localized: "The socket path is %d bytes. macOS allows %d at most."), @@ -37,8 +40,14 @@ nonisolated internal enum MySQLLocalSocket { return path } + // connect(2) stops at a NUL, so the driver would dial a different path than the one shown, and a + // control or format character hides part of the path wherever it is shown. static func issue(for path: String) -> PathIssue? { guard path.hasPrefix("/") else { return .notAbsolute } + let hidden: Set = [.control, .format] + guard !path.unicodeScalars.contains(where: { hidden.contains($0.properties.generalCategory) }) else { + return .hiddenCharacter + } let bytes = path.utf8.count guard bytes <= maximumPathBytes else { return .tooLong(bytes: bytes) } return nil diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 9bfbe44aba..b14039abac 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -592,12 +592,14 @@ enum ConnectionExportService { components.scheme = "tablepro" components.host = "import" - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "name", value: exportable.name), - URLQueryItem(name: "host", value: exportable.host), - URLQueryItem(name: "port", value: String(exportable.port)), - URLQueryItem(name: "type", value: exportable.type) - ] + var queryItems = [URLQueryItem(name: "name", value: exportable.name)] + if let socketPath = connection.localSocketPath { + queryItems.append(URLQueryItem(name: "socket", value: socketPath)) + } else { + queryItems.append(URLQueryItem(name: "host", value: exportable.host)) + queryItems.append(URLQueryItem(name: "port", value: String(exportable.port))) + } + queryItems.append(URLQueryItem(name: "type", value: exportable.type)) if !exportable.username.isEmpty { queryItems.append(URLQueryItem(name: "username", value: exportable.username)) @@ -696,7 +698,7 @@ enum ConnectionExportService { } if let fields = exportable.additionalFields { - for (key, value) in fields.sorted(by: { $0.key < $1.key }) { + for (key, value) in fields.sorted(by: { $0.key < $1.key }) where key != MySQLLocalSocket.fieldKey { queryItems.append(URLQueryItem(name: "af_\(key)", value: value)) } } diff --git a/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift b/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift new file mode 100644 index 0000000000..abccd19e44 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/DatabaseURLConnectionMatch.swift @@ -0,0 +1,20 @@ +// +// DatabaseURLConnectionMatch.swift +// TablePro +// + +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. + 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 + else { return false } + guard parsed.localSocketPath == nil else { return true } + return saved.host == parsed.host && (parsed.port == nil || saved.port == parsed.port) + } +} diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index e8a4a556ba..38f0c7bab6 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -289,11 +289,9 @@ internal enum DeeplinkParser { guard let name = value("name"), !name.isEmpty else { return .failure(.missingRequiredParam("name")) } - guard let host = value("host"), !host.isEmpty else { - return .failure(.missingRequiredParam("host")) - } + let givenHost = value("host") ?? "" guard let typeStr = value("type") else { - return .failure(.missingRequiredParam("type")) + return .failure(givenHost.isEmpty ? .missingRequiredParam("host") : .missingRequiredParam("type")) } guard let typeId = ConnectionTypeResolver.canonicalTypeId( @@ -304,6 +302,35 @@ internal enum DeeplinkParser { } let dbType = DatabaseType(rawValue: typeId) + let afItems = queryItems.filter { $0.name.hasPrefix("af_") } + var fields: [String: String] = [:] + for item in afItems { + let fieldKey = String(item.name.dropFirst(3)) + if !fieldKey.isEmpty, let fieldValue = item.value, !fieldValue.isEmpty { + fields[fieldKey] = fieldValue + } + } + + // The generic `af_localSocketPath` form is read too, under the same rules as `socket`. + let requestedSocket = MySQLLocalSocket.path(in: [ + MySQLLocalSocket.fieldKey: value("socket") ?? fields[MySQLLocalSocket.fieldKey] ?? "" + ]) + fields.removeValue(forKey: MySQLLocalSocket.fieldKey) + var socketPath: String? + if let requestedSocket, dbType.supportsLocalSocket, value("ssh") != "1" { + guard MySQLLocalSocket.issue(for: requestedSocket) == nil else { + return .failure(.invalidParameter("socket")) + } + socketPath = requestedSocket + fields[MySQLLocalSocket.fieldKey] = requestedSocket + } + let additionalFields: [String: String]? = fields.isEmpty ? nil : fields + + guard !givenHost.isEmpty || socketPath != nil else { + 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") ?? "" @@ -349,21 +376,6 @@ internal enum DeeplinkParser { sslConfig = nil } - var additionalFields: [String: String]? - let afItems = queryItems.filter { $0.name.hasPrefix("af_") } - if !afItems.isEmpty { - var fields: [String: String] = [:] - for item in afItems { - let fieldKey = String(item.name.dropFirst(3)) - if !fieldKey.isEmpty, let fieldValue = item.value, !fieldValue.isEmpty { - fields[fieldKey] = fieldValue - } - } - if !fields.isEmpty { - additionalFields = fields - } - } - let exportable = ExportableConnection( name: name, host: host, diff --git a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift index fbeb1ac05c..bff3ae838b 100644 --- a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift +++ b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift @@ -92,7 +92,8 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { private static func details(for connection: DatabaseConnection) -> [String] { var details: [String] = [ - String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") + connection.localSocketPath.map { String(format: String(localized: "Socket: %@"), $0) } + ?? String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") ] if !connection.username.isEmpty { details.append(String(format: String(localized: "User: %@"), connection.username)) diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index a2aad1bd33..d3bceaf760 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -367,13 +367,7 @@ internal final class TabRouter { } let connections = ConnectionStorage.shared.loadConnections() - let matched = connections.first { conn in - conn.type == parsed.type - && conn.host == parsed.host - && (parsed.port == nil || conn.port == parsed.port) - && conn.database == parsed.database - && (parsed.username.isEmpty || conn.username == parsed.username) - } + let matched = connections.first { DatabaseURLConnectionMatch.matches(saved: $0, parsed: parsed) } let connection: DatabaseConnection let isTransient: Bool diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 9c7423cb6c..5c2e9c58bf 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -151,7 +151,10 @@ struct ConnectionURLFormatter { result += "@" } - if connection.type.pluginTypeId == "MongoDB", + let socketPath = connection.localSocketPath + if socketPath != nil { + result += "localhost" + } else if connection.type.pluginTypeId == "MongoDB", let mongoHosts = connection.additionalFields["mongoHosts"], !mongoHosts.isEmpty { result += mongoHosts } else { @@ -167,16 +170,34 @@ struct ConnectionURLFormatter { if connection.type == .redis { pathComponent = redisPath(for: connection) } - result += "/\(pathComponent)" + // Unencoded, a `#` or `?` in the name would swallow the query, socket included. + result += "/\(pathComponent.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? pathComponent)" + var params: [String] = [] + if let socketPath { + params.append("socket=\(percentEncodeSocketPath(socketPath))") + } let query = buildQueryString(connection) if !query.isEmpty { - result += "?\(query)" + params.append(query) + } + if !params.isEmpty { + result += "?\(params.joined(separator: "&"))" } return result } + private static let socketPathAllowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + + // MySQL's documented form, `socket=%2Ftmp%2Fmysql.sock`. Only unreserved characters stay + // literal, so `+` and a space survive both percent and form decoding. + private static func percentEncodeSocketPath(_ path: String) -> String { + path.addingPercentEncoding(withAllowedCharacters: socketPathAllowed) ?? path + } + private static func redisPath(for connection: DatabaseConnection) -> String { let index = connection.redisDatabaseIndex return index == 0 ? "" : String(index) diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index db9b19eb4c..c44c8b23bb 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -42,6 +42,10 @@ struct ParsedConnectionURL { let multiHost: String? let additionalFields: [String: String] + var localSocketPath: String? { + MySQLLocalSocket.path(in: additionalFields) + } + var suggestedName: String { if let connectionName, !connectionName.isEmpty { return connectionName @@ -65,6 +69,7 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { case unsupportedScheme(String) case missingHost case invalidRedisDatabaseIndex(String) + case invalidSocketPath(MySQLLocalSocket.PathIssue) var errorDescription: String? { switch self { @@ -78,6 +83,8 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { return String(localized: "Connection URL must include a host") case .invalidRedisDatabaseIndex(let path): return String(format: String(localized: "%@ is not a Redis database index."), path) + case .invalidSocketPath(let issue): + return issue.message } } } @@ -172,8 +179,10 @@ struct ConnectionURLParser { return .failure(.invalidURL) } - guard let host = components.host, !host.isEmpty else { - return .failure(.missingHost) + let endpoint: (host: String, socketFields: [String: String]) + switch resolveEndpoint(components, type: dbType) { + case .success(let resolved): endpoint = resolved + case .failure(let error): return .failure(error) } var ext = parseQueryItems(components.queryItems, dbType: dbType) @@ -230,7 +239,7 @@ struct ConnectionURLParser { return .success(ParsedConnectionURL( type: dbType, - host: host, + host: endpoint.host, port: effectivePort, database: database, username: username, @@ -262,10 +271,57 @@ struct ConnectionURLParser { useSrv: ext.useSrv, mongoQueryParams: ext.mongoQueryParams, multiHost: nil, - additionalFields: tls.additionalFields + additionalFields: tls.additionalFields.merging(endpoint.socketFields) { _, socket in socket } )) } + private static func resolveEndpoint( + _ components: URLComponents, + type: DatabaseType + ) -> Result<(host: String, socketFields: [String: String]), ConnectionURLParseError> { + localSocketPath(in: components, type: type).flatMap { socketPath in + if let socketPath { + return .success(("localhost", [MySQLLocalSocket.fieldKey: socketPath])) + } + guard let host = components.host, !host.isEmpty else { + return .failure(.missingHost) + } + return .success((host, [:])) + } + } + + private static let socketParameterNames = ["socket", "unix_socket"] + + // The mysql client's rule: only `localhost` or no host uses the socket. A bad path fails the URL, + // since falling back to TCP on localhost can reach a different server with the same credentials. + private static func localSocketPath( + in components: URLComponents, + type: DatabaseType + ) -> Result { + let host = components.host ?? "" + guard type.supportsLocalSocket, host.isEmpty || host.lowercased() == "localhost" else { + return .success(nil) + } + let items = components.percentEncodedQueryItems ?? [] + let raw = socketParameterNames.lazy.compactMap { name in + items.first { $0.name.lowercased() == name && !($0.value ?? "").isEmpty }?.value + }.first + guard let raw else { return .success(nil) } + // Form-decoded, as URLSearchParams writes a space as `+`. MySQL's URI syntax may wrap the path in (). + guard var path = raw.replacingOccurrences(of: "+", with: " ").removingPercentEncoding else { + return .failure(.invalidURL) + } + path = path.trimmingCharacters(in: .whitespacesAndNewlines) + if path.count >= 2, path.hasPrefix("("), path.hasSuffix(")") { + path = String(path.dropFirst().dropLast()) + } + guard !path.isEmpty else { return .success(nil) } + if let issue = MySQLLocalSocket.issue(for: path) { + return .failure(.invalidSocketPath(issue)) + } + return .success(path) + } + private static func resolveDBType(from scheme: String) -> DatabaseType? { switch scheme { case "postgresql", "postgres": diff --git a/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift b/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift index 55b25f6f81..e4e2910b8c 100644 --- a/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift +++ b/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift @@ -11,10 +11,21 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { internal let database: String internal let username: String internal let scopeName: String + // Optional so entries saved before sockets still decode. Trust covers this exact path only. + internal let socketPath: String? - internal init(databaseType: String, host: String, database: String, username: String, scopeName: String) { + internal init( + databaseType: String, + host: String, + database: String, + username: String, + scopeName: String, + socketPath: String? = nil + ) { + let socket = socketPath?.trimmingCharacters(in: .whitespaces) + self.socketPath = socket?.isEmpty == false ? socket : nil self.databaseType = databaseType.lowercased() - self.host = host.trimmingCharacters(in: .whitespaces).lowercased() + self.host = self.socketPath == nil ? host.trimmingCharacters(in: .whitespaces).lowercased() : "localhost" self.database = database self.username = username self.scopeName = scopeName.trimmingCharacters(in: .whitespaces) @@ -26,7 +37,8 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { host: connection.host, database: connection.database, username: connection.username, - scopeName: scopeName ?? "" + scopeName: scopeName ?? "", + socketPath: connection.localSocketPath ) } @@ -42,6 +54,13 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { if !database.isEmpty { target += "/\(database)" } + if let socketPath { + target = String( + format: String(localized: "%1$@ on %2$@"), + target, + (socketPath as NSString).abbreviatingWithTildeInPath + ) + } guard !scopeName.isEmpty else { return "\(databaseType) \(target)" } return "\(databaseType) \(target) (\(scopeName))" } diff --git a/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift b/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift index 64854a940e..8dd69a241b 100644 --- a/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift +++ b/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift @@ -6,9 +6,15 @@ import Foundation import TableProPluginKit +extension DatabaseType { + var supportsLocalSocket: Bool { + PluginMetadataRegistry.shared.snapshot(for: self)?.connection.defaultLocalSocketPath != nil + } +} + extension DatabaseConnection { var supportsLocalSocket: Bool { - PluginMetadataRegistry.shared.snapshot(for: type)?.connection.defaultLocalSocketPath != nil + type.supportsLocalSocket } // A tunnel connects to its forwarded port, never this Mac's socket. diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 13925b498b..032e2a21df 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -165337,6 +165337,9 @@ "Snowflake" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Socket" : { + }, "Socket Path" : { "localizations" : { @@ -165377,6 +165380,9 @@ } } } + }, + "Socket: %@" : { + }, "SOCKS Proxy" : { "localizations" : { @@ -187130,6 +187136,10 @@ } } }, + "The socket path contains a control or invisible character." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The socket path is %d bytes. macOS allows %d at most." : { "extractionState" : "manual", "generatesSymbol" : false diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index b4ade4197d..1f9ebff9f3 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -36,9 +36,17 @@ struct DeeplinkImportSheet: View { TextField(String(localized: "Name"), text: $editableName) .onChange(of: editableName) { _ in checkDuplicate() } - LabeledContent(String(localized: "Host")) { - Text(hostDisplay) - .foregroundStyle(.secondary) + if let socketPath { + LabeledContent(String(localized: "Socket")) { + Text(socketPath) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } else { + LabeledContent(String(localized: "Host")) { + Text(hostDisplay) + .foregroundStyle(.secondary) + } } if !connection.database.isEmpty { @@ -103,6 +111,18 @@ struct DeeplinkImportSheet: View { .onAppear { checkDuplicate() } } + private var socketPath: String? { + guard connection.sshConfig == nil, DatabaseType(rawValue: connection.type).supportsLocalSocket else { + return nil + } + return MySQLLocalSocket.path(in: connection.additionalFields ?? [:]) + } + + private var driverOptions: [String: String] { + guard socketPath != nil else { return connection.additionalFields ?? [:] } + return (connection.additionalFields ?? [:]).filter { $0.key != MySQLLocalSocket.fieldKey } + } + private var hostDisplay: String { connection.port > 0 ? "\(connection.host):\(connection.port)" @@ -191,7 +211,8 @@ struct DeeplinkImportSheet: View { @ViewBuilder private var optionsSection: some View { - if let fields = connection.additionalFields, !fields.isEmpty { + let fields = driverOptions + if !fields.isEmpty { Section(String(localized: "Driver Options")) { ForEach(fields.keys.sorted(), id: \.self) { key in LabeledContent(key) { @@ -267,10 +288,18 @@ struct DeeplinkImportSheet: View { let existing = ConnectionStorage.shared.loadConnections() isDuplicate = existing.contains { $0.name.lowercased() == trimmed.lowercased() - && $0.host.lowercased() == connection.host.lowercased() - && $0.port == connection.port && $0.type.rawValue.lowercased() == connection.type.lowercased() + && hasSameEndpoint($0) + } + } + + private func hasSameEndpoint(_ saved: DatabaseConnection) -> Bool { + guard let socketPath else { + return saved.localSocketPath == nil + && saved.host.lowercased() == connection.host.lowercased() + && saved.port == connection.port } + return saved.localSocketPath == socketPath } private func performImport() { diff --git a/TablePro/Views/ConnectionForm/Components/ClipboardConnectionBanner.swift b/TablePro/Views/ConnectionForm/Components/ClipboardConnectionBanner.swift index 213fc3e94e..ae3e9183d2 100644 --- a/TablePro/Views/ConnectionForm/Components/ClipboardConnectionBanner.swift +++ b/TablePro/Views/ConnectionForm/Components/ClipboardConnectionBanner.swift @@ -67,12 +67,15 @@ struct ClipboardConnectionBanner: View { rendered += "@" } rendered += parsed.host - if !parsed.useSrv, parsed.resolvedPort > 0 { + if !parsed.useSrv, parsed.localSocketPath == nil, parsed.resolvedPort > 0 { rendered += ":\(parsed.resolvedPort)" } if !parsed.database.isEmpty { rendered += "/\(parsed.database)" } + if let socketPath = parsed.localSocketPath { + rendered += "?socket=\(socketPath)" + } if rendered.count > 60 { let prefix = rendered.prefix(48) let suffix = rendered.suffix(8) diff --git a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift index 82cabf6201..7a7d21efbc 100644 --- a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift +++ b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift @@ -120,7 +120,9 @@ struct ImportFromURLSheet: View { previewRow(String(localized: "Host"), parsed.host) } case .network: - if let multiHost = parsed.multiHost, multiHost.contains(",") { + if let socketPath = parsed.localSocketPath { + previewRow(String(localized: "Socket"), socketPath) + } else if let multiHost = parsed.multiHost, multiHost.contains(",") { previewRow(String(localized: "Hosts"), multiHost) } else if !parsed.host.isEmpty { let portStr = parsed.port.map { ":\($0)" } ?? "" diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 960dcf2624..2f9372eea3 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -887,8 +887,14 @@ final class ConnectionFormCoordinator: ObservableObject { advanced.resetForType(parsed.type) } - // Left on Socket, normalizeTransport would drop the SSH server the URL names. - network.endpoint = .hostAndPort + if let socketPath = parsed.localSocketPath, network.supportsLocalSocket { + network.endpoint = .localSocket + network.localSocketPath = socketPath + transport = nil + } else { + // Left on Socket, normalizeTransport would drop the SSH server the URL names. + network.endpoint = .hostAndPort + } network.host = parsed.host network.port = String(parsed.resolvedPort) network.database = parsed.database @@ -966,7 +972,7 @@ final class ConnectionFormCoordinator: ObservableObject { if let svcName = parsed.oracleServiceName, !svcName.isEmpty { writeFieldByRegistry("oracleServiceName", value: svcName) } - for (fieldId, value) in parsed.additionalFields { + for (fieldId, value) in parsed.additionalFields where fieldId != MySQLLocalSocket.fieldKey { writeFieldByRegistry(fieldId, value: value) } if let hex = parsed.statusColor, !hex.isEmpty { @@ -1059,9 +1065,11 @@ final class ConnectionFormCoordinator: ObservableObject { connectionStorage: ConnectionStorage ) -> Bool { connectionStorage.loadConnections().contains { saved in - saved.host == parsed.host - && saved.port == parsed.resolvedPort - && saved.username == parsed.username + guard saved.username == parsed.username, saved.localSocketPath == parsed.localSocketPath else { + return false + } + return parsed.localSocketPath != nil + || (saved.host == parsed.host && saved.port == parsed.resolvedPort) } } } diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index e3fcc38ce7..21674456c8 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -17980,6 +17980,9 @@ } } } + }, + "The socket path contains a control or invisible character." : { + }, "The socket path is %d bytes. macOS allows %d at most." : { "localizations" : { diff --git a/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift b/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift new file mode 100644 index 0000000000..585b3513a9 --- /dev/null +++ b/TableProTests/Core/Services/ConnectionImportLinkSocketTests.swift @@ -0,0 +1,153 @@ +// +// ConnectionImportLinkSocketTests.swift +// TableProTests +// + +import Foundation +import TableProImport +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct ConnectionImportLinkSocketTests { + private func importLink(_ items: [URLQueryItem]) throws -> URL { + var components = URLComponents() + components.scheme = "tablepro" + components.host = "import" + components.queryItems = [URLQueryItem(name: "name", value: "Local")] + items + return try #require(components.url) + } + + private func imported(_ items: [URLQueryItem]) throws -> ExportableConnection { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(try importLink(items)) else { + throw DeeplinkError.malformedPath("not an import") + } + return parsed + } + + private func savedConnection(from exportable: ExportableConnection) -> DatabaseConnection { + ConnectionExportService.buildDatabaseConnection( + id: UUID(), from: exportable, name: exportable.name, tagIdsByName: [:], groupIdsByName: [:] + ) + } + + @Test("A link with a socket and no host imports a localhost socket connection") + func socketWithoutHost() throws { + let path = "/Users/me/Library/Application Support/Local/run/ab12/mysql/mysqld.sock" + let exportable = try imported([ + URLQueryItem(name: "type", value: "MySQL"), + URLQueryItem(name: "socket", value: path), + URLQueryItem(name: "username", value: "root"), + URLQueryItem(name: "database", value: "local") + ]) + + #expect(exportable.host == "localhost") + let connection = savedConnection(from: exportable) + #expect(connection.localSocketPath == path) + #expect(connection.database == "local") + } + + @Test("af_localSocketPath is read like socket") + func genericFieldStillWorks() throws { + let exportable = try imported([ + URLQueryItem(name: "type", value: "MariaDB"), + URLQueryItem(name: "host", value: "localhost"), + URLQueryItem(name: "af_localSocketPath", value: "/tmp/mysql.sock") + ]) + #expect(savedConnection(from: exportable).localSocketPath == "/tmp/mysql.sock") + } + + @Test( + "A path that cannot be a socket refuses the link", + arguments: ["mysql.sock", "~/mysql.sock", "/" + String(repeating: "a", count: 103), "/tmp/prod.sock\u{0}/../dev.sock"] + ) + func invalidSocketRefused(_ path: String) throws { + for name in ["socket", "af_localSocketPath"] { + let url = try importLink([ + URLQueryItem(name: "type", value: "MySQL"), + URLQueryItem(name: name, value: path) + ]) + guard case .failure(let error) = DeeplinkParser.parse(url) else { + Issue.record("Expected \(name)=\(path) to be refused"); continue + } + #expect(error == .invalidParameter("socket")) + } + } + + @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") + ]) + guard case .failure(let error) = DeeplinkParser.parse(url) else { + Issue.record("Expected failure"); return + } + #expect(error == .missingRequiredParam("host")) + + let withHost = try imported([ + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "host", value: "db.example.com"), + URLQueryItem(name: "af_localSocketPath", value: "/tmp/mysql.sock") + ]) + #expect(withHost.additionalFields?[MySQLLocalSocket.fieldKey] == nil) + } + + @Test("A link through an SSH tunnel drops the socket, the tunnel decides") + func sshDropsSocket() throws { + let exportable = try imported([ + URLQueryItem(name: "type", value: "MySQL"), + URLQueryItem(name: "host", value: "127.0.0.1"), + URLQueryItem(name: "socket", value: "/tmp/mysql.sock"), + URLQueryItem(name: "ssh", value: "1"), + URLQueryItem(name: "sshHost", value: "bastion.example.com") + ]) + #expect(exportable.additionalFields?[MySQLLocalSocket.fieldKey] == nil) + } + + @Test("A link with neither host nor socket still asks for host") + func missingHost() throws { + let url = try importLink([URLQueryItem(name: "type", value: "MySQL")]) + guard case .failure(let error) = DeeplinkParser.parse(url) else { + Issue.record("Expected failure"); return + } + #expect(error == .missingRequiredParam("host")) + } + + @Test("Copy TablePro Link writes socket= in place of host and port, and reads back") + func builderRoundTrip() throws { + let path = "/Users/me/Library/Application Support/Local/run/ab12/mysql/a+b.sock" + var original = DatabaseConnection( + name: "Local", host: "db.internal", port: 3_307, database: "local", username: "root", type: .mysql + ) + original.localSocketPath = path + + let link = try #require(ConnectionExportService.buildImportDeeplink(for: original)) + let items = URLComponents(string: link)?.queryItems ?? [] + #expect(items.first { $0.name == "socket" }?.value == path) + #expect(!items.contains { $0.name == "host" || $0.name == "port" || $0.name == "af_localSocketPath" }) + + let url = try #require(URL(string: link)) + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { + Issue.record("Failed to parse \(link)"); return + } + #expect(savedConnection(from: parsed).localSocketPath == path) + } + + @Test("A TCP connection's link has no socket") + func builderWithoutSocket() throws { + var tunnelled = DatabaseConnection( + name: "Tunnel", host: "127.0.0.1", port: 3_306, database: "db", username: "root", type: .mysql + ) + tunnelled.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" + tunnelled.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + + let link = try #require(ConnectionExportService.buildImportDeeplink(for: tunnelled)) + let names = Set((URLComponents(string: link)?.queryItems ?? []).map(\.name)) + #expect(names.contains("host")) + #expect(!names.contains("socket")) + #expect(!names.contains("af_localSocketPath")) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/DatabaseURLConnectionMatchTests.swift b/TableProTests/Core/Services/Infrastructure/DatabaseURLConnectionMatchTests.swift new file mode 100644 index 0000000000..29af39a4a3 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/DatabaseURLConnectionMatchTests.swift @@ -0,0 +1,83 @@ +// +// DatabaseURLConnectionMatchTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct DatabaseURLConnectionMatchTests { + private let siteA = "/Users/me/Library/Application Support/Local/run/aaa/mysql/mysqld.sock" + private let siteB = "/Users/me/Library/Application Support/Local/run/bbb/mysql/mysqld.sock" + + private func saved(socket: String? = nil, host: String = "localhost", port: Int = 3_306) -> DatabaseConnection { + var connection = DatabaseConnection( + name: "Site", host: host, port: port, database: "local", username: "root", type: .mysql + ) + connection.localSocketPath = socket + return connection + } + + private func parsed(_ url: String) throws -> ParsedConnectionURL { + try ConnectionURLParser.parse(url).get() + } + + private func socketURL(_ path: String, scheme: String = "mysql", user: String = "root", database: String = "local") -> String { + let encoded = path.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? path + return "\(scheme)://\(user)@localhost/\(database)?socket=\(encoded)" + } + + @Test("Two saved sites that differ only by socket each open from their own link") + func socketPicksTheSite() throws { + let connections = [saved(socket: siteA), saved(socket: siteB)] + + let forB = try parsed(socketURL(siteB)) + let matched = connections.filter { DatabaseURLConnectionMatch.matches(saved: $0, parsed: forB) } + + #expect(matched.count == 1) + #expect(matched.first?.localSocketPath == siteB) + } + + @Test("A socket link never opens a saved TCP connection to the same database") + func socketLinkSkipsTCP() throws { + #expect(!DatabaseURLConnectionMatch.matches(saved: saved(), parsed: try parsed(socketURL(siteA)))) + } + + @Test("A TCP link never opens a saved socket connection") + func tcpLinkSkipsSocket() throws { + let link = try parsed("mysql://root@localhost/local") + #expect(!DatabaseURLConnectionMatch.matches(saved: saved(socket: siteA), parsed: link)) + #expect(DatabaseURLConnectionMatch.matches(saved: saved(), parsed: link)) + } + + @Test("A saved socket connection matches whatever its hidden host and port say") + func socketIgnoresHiddenHost() throws { + let connection = saved(socket: siteA, host: "db.internal", port: 3_307) + #expect(DatabaseURLConnectionMatch.matches(saved: connection, parsed: try parsed(socketURL(siteA)))) + } + + @Test("Type, database and user still have to agree on a socket link") + func socketStillComparesTheRest() throws { + let connection = saved(socket: siteA) + let otherDatabase = try parsed(socketURL(siteA, database: "other")) + let otherUser = try parsed(socketURL(siteA, user: "admin")) + let mariadb = try parsed(socketURL(siteA, scheme: "mariadb")) + + #expect(!DatabaseURLConnectionMatch.matches(saved: connection, parsed: otherDatabase)) + #expect(!DatabaseURLConnectionMatch.matches(saved: connection, parsed: otherUser)) + #expect(!DatabaseURLConnectionMatch.matches(saved: connection, parsed: mariadb)) + } + + @Test("A TCP link still matches on host and an explicit port") + func tcpMatchesHostAndPort() throws { + let connection = saved(host: "db.example.com", port: 3_307) + #expect(DatabaseURLConnectionMatch.matches(saved: connection, parsed: try parsed("mysql://root@db.example.com:3307/local"))) + #expect(DatabaseURLConnectionMatch.matches(saved: connection, parsed: try parsed("mysql://root@db.example.com/local"))) + #expect(!DatabaseURLConnectionMatch.matches(saved: connection, parsed: try parsed("mysql://root@db.example.com:3308/local"))) + #expect(!DatabaseURLConnectionMatch.matches(saved: connection, parsed: try parsed("mysql://root@other.example.com/local"))) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift b/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift index 42d11fc4c8..b104ddeb4a 100644 --- a/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift @@ -50,6 +50,15 @@ struct ExternalConnectionGateTests { return connection } + private func localSiteConnection(socket: String = "/tmp/site-a.sock") -> DatabaseConnection { + var connection = DatabaseConnection( + name: "Local", host: "localhost", port: 3_306, + database: "local", username: "root", type: .mysql + ) + connection.localSocketPath = socket + return connection + } + private func remoteConnection() -> DatabaseConnection { DatabaseConnection( name: "Prod", host: "db.example.com", port: 3_306, @@ -210,4 +219,49 @@ struct ExternalConnectionGateTests { #expect(await gate.authorize(tunnelledConnection(), scopeName: "ddev-shop")) #expect(store.entries().isEmpty) } + + @Test("A trusted localhost does not cover a link that names a socket") + func trustedHostAsksForSocket() async throws { + let store = try makeStore() + var tcp = localSiteConnection() + tcp.localSocketPath = nil + store.trust(ExternalConnectionTrustKey(connection: tcp, scopeName: "Local")) + let prompt = SpyPrompt(decision: .connect) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + #expect(await gate.authorize(localSiteConnection(), scopeName: "Local")) + #expect(prompt.callCount == 1) + #expect(prompt.offeredAlwaysAllow == true) + } + + @Test("Always Allow on a socket link trusts that socket and no other") + func alwaysAllowIsKeyedOnTheSocket() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .alwaysAllow) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + #expect(await gate.authorize(localSiteConnection(), scopeName: "Local")) + #expect(await gate.authorize(localSiteConnection(), scopeName: "Local")) + #expect(prompt.callCount == 1) + + _ = await gate.authorize(localSiteConnection(socket: "/tmp/site-b.sock"), scopeName: "Local") + #expect(prompt.callCount == 2) + + var tcp = localSiteConnection() + tcp.localSocketPath = nil + _ = await gate.authorize(tcp, scopeName: "Local") + #expect(prompt.callCount == 3) + } + + @Test("A socket link with a filter still asks every time") + func socketWithFilterAsks() async throws { + let store = try makeStore() + store.trust(ExternalConnectionTrustKey(connection: localSiteConnection(), scopeName: "Local")) + let prompt = SpyPrompt(decision: .connect) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + _ = await gate.authorize(localSiteConnection(), scopeName: "Local", filter: .condition("id > 0")) + #expect(prompt.callCount == 1) + #expect(prompt.offeredAlwaysAllow == false) + } } diff --git a/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift b/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift index 9f39e948f1..2c57b1e2c4 100644 --- a/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift +++ b/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift @@ -175,4 +175,69 @@ struct ExternalConnectionTrustStoreTests { #expect(store.entries().isEmpty) #expect(store.isTrusted(lookalike) == false) } + + @Test("A socket key is local and matches only its own path") + func socketKeyMatchesItsPath() throws { + let store = try makeStore() + let socketKey = ExternalConnectionTrustKey( + databaseType: "MySQL", host: "", database: "local", username: "root", + scopeName: "Local", socketPath: "/tmp/site-a.sock" + ) + store.trust(socketKey) + + #expect(socketKey.isLoopbackHost) + #expect(store.isTrusted(socketKey)) + #expect(!store.isTrusted(ExternalConnectionTrustKey( + databaseType: "MySQL", host: "localhost", database: "local", username: "root", + scopeName: "Local", socketPath: "/tmp/site-b.sock" + ))) + #expect(!store.isTrusted(ExternalConnectionTrustKey( + databaseType: "MySQL", host: "localhost", database: "local", username: "root", scopeName: "Local" + ))) + } + + @Test("A socket connection's hidden host does not change its key") + func socketKeyIgnoresHost() { + var connection = DatabaseConnection( + name: "Local", host: "db.internal", port: 3_307, database: "local", username: "root", type: .mysql + ) + connection.localSocketPath = "/tmp/mysql.sock" + let key = ExternalConnectionTrustKey(connection: connection, scopeName: "Local") + + #expect(key.socketPath == "/tmp/mysql.sock") + #expect(key.host == "localhost") + #expect(key.isLoopbackHost) + #expect(key.displayDescription.contains("/tmp/mysql.sock")) + } + + @Test("Entries saved before sockets still decode and stay trusted") + func legacyEntriesDecode() throws { + let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let legacy = """ + [{"key":{"databaseType":"mysql","host":"127.0.0.1","database":"db","username":"db",\ + "scopeName":"ddev-shop"},"trustedAt":0}] + """ + defaults.set(Data(legacy.utf8), forKey: "com.TablePro.externalConnectionTrust.entries") + + let store = ExternalConnectionTrustStore(defaults: defaults) + + #expect(store.entries().count == 1) + #expect(store.isTrusted(loopbackKey())) + } + + @Test("A poisoned entry pairing a remote host with a socket is not trusted") + func poisonedSocketEntryIsIgnored() throws { + let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let poisoned = """ + [{"key":{"databaseType":"mysql","host":"db.evil.example.com","database":"db","username":"db",\ + "scopeName":"","socketPath":"/tmp/mysql.sock"},"trustedAt":0}] + """ + defaults.set(Data(poisoned.utf8), forKey: "com.TablePro.externalConnectionTrust.entries") + + #expect(ExternalConnectionTrustStore(defaults: defaults).entries().isEmpty) + } } diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index f96141f688..3273055c0b 100644 --- a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -5,9 +5,10 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @MainActor struct ConnectionURLFormatterTests { // MARK: - Basic URLs @@ -653,4 +654,75 @@ struct ConnectionURLFormatterTests { #expect(parsed.port == 9_495) #expect(parsed.database == "remotedb") } + + // MARK: - Unix socket + + private func socketConnection( + _ path: String, + type: DatabaseType = .mysql, + host: String = "db.internal", + port: Int = 3_307 + ) -> DatabaseConnection { + DatabaseConnection( + name: "", host: host, port: port, database: "local", username: "root", type: type, + sslConfig: SSLConfiguration(mode: type.defaultSSLMode), + additionalFields: [MySQLLocalSocket.fieldKey: path] + ) + } + + @Test("A socket connection copies as localhost, no port, and a percent-encoded socket") + func socketConnectionURL() { + let url = ConnectionURLFormatter.format(socketConnection("/tmp/mysql.sock"), password: "pw", sshPassword: nil) + #expect(url == "mysql://root:pw@localhost/local?socket=%2Ftmp%2Fmysql.sock") + } + + @Test( + "A socket URL parses back to the same socket", + arguments: [ + "/tmp/mysql.sock", + "/Users/me/Library/Application Support/Local/run/ab12/mysql/mysqld.sock", + "/tmp/a+b&c=d.sock", + "/tmp/(x).sock" + ] + ) + func socketRoundTrip(_ path: String) throws { + for type in [DatabaseType.mysql, .mariadb] { + let url = ConnectionURLFormatter.format(socketConnection(path, type: type), password: nil, sshPassword: nil) + let parsed = try ConnectionURLParser.parse(url).get() + #expect(parsed.type == type) + #expect(parsed.localSocketPath == path) + #expect(parsed.host == "localhost") + #expect(parsed.database == "local") + #expect(parsed.username == "root") + } + } + + @Test( + "A database name with URL delimiters keeps its socket and parses back", + arguments: ["shop#archive", "a?b", "100%", "my db", "a/b"] + ) + func socketWithDelimitersInDatabase(_ database: String) throws { + var connection = socketConnection("/tmp/mysql.sock") + connection.database = database + let url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) + let parsed = try ConnectionURLParser.parse(url).get() + #expect(parsed.database == database) + #expect(parsed.localSocketPath == "/tmp/mysql.sock") + } + + @Test("A socket connection under an SSH tunnel copies the tunnel, not the socket") + func socketUnderTunnelIsNotWritten() { + var connection = socketConnection("/tmp/mysql.sock") + connection.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + let url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) + #expect(url.hasPrefix("mysql+ssh://bastion.example.com/root@db.internal:3307/local")) + #expect(!url.contains("socket")) + } + + @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 url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) + #expect(url == "postgresql://root@db.example.com/local") + } } diff --git a/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift new file mode 100644 index 0000000000..9db467b712 --- /dev/null +++ b/TableProTests/Core/Utilities/ConnectionURLParserSocketTests.swift @@ -0,0 +1,187 @@ +// +// ConnectionURLParserSocketTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct ConnectionURLParserSocketTests { + private func parsed(_ url: String) throws -> ParsedConnectionURL { + try ConnectionURLParser.parse(url).get() + } + + @Test("socket= on a localhost MySQL URL is the socket path") + func socketParameter() throws { + let parsed = try parsed("mysql://root:pw@localhost/shop?socket=%2Ftmp%2Fmysql.sock") + #expect(parsed.localSocketPath == "/tmp/mysql.sock") + #expect(parsed.host == "localhost") + #expect(parsed.database == "shop") + #expect(parsed.username == "root") + #expect(parsed.password == "pw") + } + + @Test("unix_socket is read as socket, and socket wins when both are given") + func unixSocketAlias() throws { + #expect(try parsed("mysql://root@localhost/db?unix_socket=/tmp/mysql.sock").localSocketPath == "/tmp/mysql.sock") + let both = try parsed("mysql://root@localhost/db?unix_socket=/tmp/b.sock&socket=/tmp/a.sock") + #expect(both.localSocketPath == "/tmp/a.sock") + } + + @Test("The parameter name is case-insensitive") + func caseInsensitiveName() throws { + #expect(try parsed("mysql://root@localhost/db?SOCKET=/tmp/mysql.sock").localSocketPath == "/tmp/mysql.sock") + } + + @Test("The link a URLSearchParams caller builds keeps the space in Application Support") + func formEncodedSpace() throws { + let url = "mysql://root:root@localhost/local?socket=%2FUsers%2Fme%2FLibrary%2FApplication+Support" + + "%2FLocal%2Frun%2Fab12%2Fmysql%2Fmysqld.sock&env=local&name=My+Site&safeModeLevel=0" + let parsed = try parsed(url) + #expect(parsed.localSocketPath == "/Users/me/Library/Application Support/Local/run/ab12/mysql/mysqld.sock") + #expect(parsed.connectionName == "My Site") + #expect(parsed.safeModeLevel == 0) + } + + @Test("A percent-encoded plus stays a plus") + func encodedPlus() throws { + #expect(try parsed("mysql://root@localhost/db?socket=%2Ftmp%2Fa%2Bb.sock").localSocketPath == "/tmp/a+b.sock") + } + + @Test("MySQL's parenthesized form drops the parentheses") + func parenthesizedPath() throws { + #expect(try parsed("mysql://root@localhost/db?socket=(/tmp/mysql.sock)").localSocketPath == "/tmp/mysql.sock") + } + + @Test("An empty host becomes localhost when a socket is given") + func emptyHostWithSocket() throws { + let parsed = try parsed("mysql://root@/local?socket=/tmp/mysql.sock") + #expect(parsed.host == "localhost") + #expect(parsed.localSocketPath == "/tmp/mysql.sock") + #expect(parsed.database == "local") + } + + @Test("An empty host without a socket still needs a host") + func emptyHostWithoutSocket() { + guard case .failure(let error) = ConnectionURLParser.parse("mysql://root@/local") else { + Issue.record("Expected failure"); return + } + #expect(error == .missingHost) + } + + @Test("LOCALHOST counts as localhost") + func uppercaseLocalhost() throws { + let parsed = try parsed("mysql://root@LOCALHOST/db?socket=/tmp/mysql.sock") + #expect(parsed.localSocketPath == "/tmp/mysql.sock") + #expect(parsed.host == "localhost") + } + + @Test( + "Any other host stays on TCP and ignores socket, as the mysql client does", + arguments: ["127.0.0.1", "db.example.com", "[::1]"] + ) + func otherHostIgnoresSocket(_ host: String) throws { + let parsed = try parsed("mysql://root@\(host)/db?socket=/tmp/mysql.sock") + #expect(parsed.localSocketPath == nil) + #expect(parsed.additionalFields[MySQLLocalSocket.fieldKey] == nil) + } + + @Test("MariaDB URLs take a socket too") + func mariadb() throws { + let parsed = try parsed("mariadb://root@localhost/db?socket=/tmp/mysql.sock") + #expect(parsed.type == .mariadb) + #expect(parsed.localSocketPath == "/tmp/mysql.sock") + } + + @Test( + "Engines without a socket mode ignore socket", + arguments: [ + "postgresql://u@localhost/db?socket=/tmp/.s.PGSQL.5432", + "tidb://root@localhost/db?socket=/tmp/mysql.sock", + "redis://localhost?socket=/tmp/redis.sock" + ] + ) + func otherEnginesIgnoreSocket(_ url: String) throws { + let parsed = try parsed(url) + #expect(parsed.localSocketPath == nil) + #expect(parsed.host == "localhost") + } + + @Test("A PostgreSQL URL with no host still needs one, socket or not") + func postgresEmptyHost() { + guard case .failure(let error) = ConnectionURLParser.parse("postgresql://u@/db?socket=/tmp/x") else { + Issue.record("Expected failure"); return + } + #expect(error == .missingHost) + } + + @Test("A relative socket path fails the URL") + func relativePathFails() { + guard case .failure(let error) = ConnectionURLParser.parse("mysql://root@localhost/db?socket=mysql.sock") else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidSocketPath(.notAbsolute)) + #expect(error.errorDescription == MySQLLocalSocket.PathIssue.notAbsolute.message) + } + + @Test("A home-relative socket path fails the URL") + func tildePathFails() { + guard case .failure(let error) = ConnectionURLParser.parse("mysql://root@localhost/db?socket=~/mysql.sock") else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidSocketPath(.notAbsolute)) + } + + @Test( + "A percent-encoded NUL or line break in the socket fails the URL, under either name", + arguments: ["socket", "unix_socket"], ["%2Ftmp%2Fprod.sock%00%2F..%2Fdev.sock", "%2Ftmp%2Fa%0Ab.sock"] + ) + func hiddenCharacterFails(_ name: String, _ value: String) { + guard case .failure(let error) = ConnectionURLParser.parse("mysql://root@localhost/db?\(name)=\(value)") else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidSocketPath(.hiddenCharacter)) + } + + @Test("103 bytes is the longest socket path, 104 fails") + func lengthLimit() throws { + let longest = "/" + String(repeating: "a", count: MySQLLocalSocket.maximumPathBytes - 1) + #expect(try parsed("mysql://root@localhost/db?socket=\(longest)").localSocketPath == longest) + + let tooLong = longest + "a" + guard case .failure(let error) = ConnectionURLParser.parse("mysql://root@localhost/db?socket=\(tooLong)") else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidSocketPath(.tooLong(bytes: 104))) + } + + @Test("An empty socket parameter is ignored") + func emptyValue() throws { + #expect(try parsed("mysql://root@localhost/db?socket=").localSocketPath == nil) + } + + @Test("A socket in an SSH URL is ignored, the tunnel decides") + func sshURLIgnoresSocket() throws { + let parsed = try parsed("mysql+ssh://deploy@bastion.example.com/root@localhost/db?socket=/tmp/mysql.sock") + #expect(parsed.sshHost == "bastion.example.com") + #expect(parsed.localSocketPath == nil) + } + + @Test("The port in a socket URL is not the endpoint") + func socketURLWithPort() throws { + let parsed = try parsed("mysql://root@localhost:3307/db?socket=/tmp/mysql.sock") + #expect(parsed.localSocketPath == "/tmp/mysql.sock") + } + + @Test("A socket URL builds a transient connection on that socket") + @MainActor + func transientConnection() throws { + let parsed = try parsed("mysql://root@/local?socket=/tmp/mysql.sock&name=Local") + let connection = TransientConnectionFactory.build(from: parsed) + #expect(connection.localSocketPath == "/tmp/mysql.sock") + #expect(connection.host == "localhost") + } +} diff --git a/TableProTests/Plugins/MySQLLocalSocketTests.swift b/TableProTests/Plugins/MySQLLocalSocketTests.swift index 1c92d1c426..6f7b3d7687 100644 --- a/TableProTests/Plugins/MySQLLocalSocketTests.swift +++ b/TableProTests/Plugins/MySQLLocalSocketTests.swift @@ -34,6 +34,20 @@ struct MySQLLocalSocketTests { #expect(MySQLLocalSocket.issue(for: MySQLLocalSocket.defaultPath) == nil) } + @Test( + "A NUL, control or invisible character is rejected", + arguments: ["/tmp/prod.sock\u{0}/../dev.sock", "/tmp/a\nb.sock", "/tmp/\u{202E}kcos.b", "/tmp/a\u{200B}.sock", "/tmp/a\u{7F}.sock"] + ) + func hiddenCharacters(_ path: String) { + #expect(MySQLLocalSocket.issue(for: path) == .hiddenCharacter) + } + + @Test("A space or non-ASCII letter is still a valid path") + func visibleCharacters() { + #expect(MySQLLocalSocket.issue(for: "/Users/me/Library/Application Support/Local/mysqld.sock") == nil) + #expect(MySQLLocalSocket.issue(for: "/tmp/caf\u{E9}.sock") == nil) + } + @Test("The limit is 103 UTF-8 bytes, not 103 characters") func byteLimit() { let twoByte = "\u{E9}" diff --git a/TableProTests/ViewModels/ConnectionFormClipboardTests.swift b/TableProTests/ViewModels/ConnectionFormClipboardTests.swift index 77539a797a..39b82df5d6 100644 --- a/TableProTests/ViewModels/ConnectionFormClipboardTests.swift +++ b/TableProTests/ViewModels/ConnectionFormClipboardTests.swift @@ -137,4 +137,66 @@ struct ConnectionFormClipboardTests { let candidate = try #require(ClipboardConnectionCandidate(clipboardText: url)) #expect(ClipboardConnectionBanner.summary(for: candidate) == url) } + + @Test( + "A socket URL opens the form on Socket with its path, from the clipboard or Import from URL", + arguments: [ + "mysql://root@localhost/local?socket=%2Ftmp%2Fmysql.sock", + "mariadb://root@/local?unix_socket=/tmp/mysql.sock" + ] + ) + func socketURLSelectsSocket(_ url: String) throws { + for coordinator in [try formApplyingClipboard(url), try formImporting(url)] { + #expect(coordinator.network.endpoint == .localSocket) + #expect(coordinator.network.localSocketPath == "/tmp/mysql.sock") + #expect(coordinator.transport == nil) + #expect(coordinator.advanced.additionalFieldValues[MySQLLocalSocket.fieldKey] == nil) + + let edits = coordinator.buildEdits() + #expect(edits.additionalFields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") + #expect(edits.applied(to: DatabaseConnection(name: "")).localSocketPath == "/tmp/mysql.sock") + } + } + + @Test("A socket URL pasted over an SSH form drops the tunnel") + func socketURLClearsTheTransport() throws { + let candidate = try #require( + ClipboardConnectionCandidate(clipboardText: "mysql://root@localhost/local?socket=/tmp/mysql.sock") + ) + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.start() + coordinator.transport = .ssh + coordinator.ssh.state.host = "bastion.example.com" + + coordinator.applyClipboardCandidate(candidate) + + #expect(coordinator.network.endpoint == .localSocket) + #expect(coordinator.transport == nil) + #expect(coordinator.availableTransports == [nil]) + } + + @Test("A TCP URL pasted after a socket one goes back to Host and Port") + func tcpURLLeavesSocket() throws { + let socket = try #require( + ClipboardConnectionCandidate(clipboardText: "mysql://root@localhost/local?socket=/tmp/mysql.sock") + ) + let tcp = try #require(ClipboardConnectionCandidate(clipboardText: "mysql://root@db.example.com/shop")) + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.start() + + coordinator.applyClipboardCandidate(socket) + coordinator.applyClipboardCandidate(tcp) + + #expect(coordinator.network.endpoint == .hostAndPort) + #expect(coordinator.network.host == "db.example.com") + #expect(coordinator.buildEdits().additionalFields[MySQLLocalSocket.fieldKey] == nil) + } + + @Test("The banner summary names the socket and no port") + func summaryShowsTheSocket() throws { + let candidate = try #require( + ClipboardConnectionCandidate(clipboardText: "mysql://root:pw@localhost/db?socket=/tmp/my.sock") + ) + #expect(ClipboardConnectionBanner.summary(for: candidate) == "mysql://root:***@localhost/db?socket=/tmp/my.sock") + } } diff --git a/docs/connections/urls.mdx b/docs/connections/urls.mdx index b204caa330..fafc448fed 100644 --- a/docs/connections/urls.mdx +++ b/docs/connections/urls.mdx @@ -115,6 +115,20 @@ Names are matched case-insensitively. Anything unrecognized is ignored, except o | `safeModeLevel` | [Safe Mode](/features/safe-mode) for this connection: 0=silent, 1=alert, 2=read-only | `?safeModeLevel=2` | | `statusColor` | Connection color as hex, matched to the nearest palette color | `?statusColor=FF3B30` | | `env` | Assign an existing tag by name. `environment` is accepted too | `?env=production` | +| `socket` | MySQL and MariaDB: the server's socket file on this Mac, in place of host and port. `unix_socket` is accepted too. See [Unix socket](#unix-socket) | `?socket=%2Ftmp%2Fmysql.sock` | + +### Unix socket + +```text +mysql://root@localhost/shop?socket=%2Ftmp%2Fmysql.sock +mariadb://root@/shop?socket=(/tmp/mysql.sock) +``` + +- The host must be `localhost` or left out. Any other host, `127.0.0.1` included, ignores `socket` and connects over TCP, as the `mysql` client does. +- Percent-encode the path or wrap it in parentheses. A `+` reads as a space, so a literal `+` is `%2B`. +- A path that does not start with `/`, holds a control or invisible character, or is longer than 103 bytes refuses the URL. +- Other engines and `+ssh` URLs ignore `socket`. +- **Copy Connection String** on a socket connection writes this form, with no port. ### SSL/TLS @@ -218,9 +232,9 @@ When another client also handles the scheme, `open -b com.TablePro "mysql://…" ## Confirmation and trusted links -Every URL opened from outside the app raises an **Open External Database Connection?** alert naming the database type, host and port, user, and database. **Connect** does not answer the Return key. Escape is the only keyboard answer, and it cancels. +Every URL opened from outside the app raises an **Open External Database Connection?** alert naming the database type, host and port or socket, user, and database. **Connect** does not answer the Return key. Escape is the only keyboard answer, and it cancels. -**Always Allow** appears only for a database on this machine (`localhost`, `127.x.x.x`, `::1`). A trusted link is matched on database type, host, database, username, and the URL's `name` parameter, never the port, so the same link on a different port still connects without asking again. Remote hosts always prompt, and so does a link through an SSH tunnel, whose database is on the SSH server. +**Always Allow** appears only for a database on this machine (`localhost`, `127.x.x.x`, `::1`, or a socket). A trusted link is matched on database type, host, socket, database, username, and the URL's `name` parameter, never the port, so the same link on a different port still connects without asking again. Trusting `localhost` does not cover a link that names a socket, and trusting one socket does not cover another. Remote hosts always prompt, and so does a link through an SSH tunnel, whose database is on the SSH server. A link with a [filter](#filtering) asks every time and offers no **Always Allow**. Read the filter in the box under the target before choosing **Connect**: it runs on the table as soon as the connection opens. diff --git a/docs/databases/mariadb.mdx b/docs/databases/mariadb.mdx index b81210bf97..56846202a3 100644 --- a/docs/databases/mariadb.mdx +++ b/docs/databases/mariadb.mdx @@ -11,9 +11,10 @@ Both schemes open a connection; `mariadb://` creates a MariaDB connection, `mysq ```text mariadb://user:password@host:3306/database +mariadb://user:password@localhost/database?socket=%2Ftmp%2Fmysql.sock ``` -See [Connection URL Reference](/connections/urls). +`socket` works as it does on [MySQL](/databases/mysql#connection-url). See [Connection URL Reference](/connections/urls). ## Differences from MySQL diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index cb2fd3d713..dc9635451d 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -56,9 +56,10 @@ The note under the field says when nothing is at the path or the file there is n ```text mysql://user:password@host:3306/database +mysql://user:password@localhost/database?socket=%2Ftmp%2Fmysql.sock ``` -Opening a URL connects directly. See [Connection URL Reference](/connections/urls). +`socket` connects through the socket file instead of host and port, and only with `localhost` or no host. Opening a URL connects directly. See [Connection URL Reference](/connections/urls#unix-socket). ## Common setups diff --git a/docs/developers/connection-import.mdx b/docs/developers/connection-import.mdx index cf5c949c03..4c9512c1f1 100644 --- a/docs/developers/connection-import.mdx +++ b/docs/developers/connection-import.mdx @@ -11,7 +11,7 @@ field, and a review sheet the user reads before anything is saved. tablepro://import?name=Staging&host=db.example.com&port=5432&type=postgresql&username=admin ``` -`name`, `host` and `type` are required. `type` takes any registered database type name, +`name`, `host` and `type` are required, except that `socket` stands in for `host`. `type` takes any registered database type name, case-insensitive: `MySQL`, `PostgreSQL`, `MongoDB`, `Redis`, `ClickHouse`, `Oracle`, `DuckDB`, `Cassandra`, and every other type a loaded plugin registers. @@ -24,6 +24,7 @@ values that contain spaces: `sslMode=Verify%20CA`. | Parameter | Description | |-----------|-------------| | `port` | Server port. Defaults to the type's standard port. | +| `socket` | MySQL and MariaDB: the absolute path of the server's socket file on this Mac, at most 103 bytes. Replaces `host` and `port`, and `host` defaults to `localhost`. Ignored for other types and with `ssh=1`. A relative or longer path, or one holding a control or invisible character, refuses the link. `af_localSocketPath` is read the same way. | | `username` | Database username. | | `database` | Default database name. | | `color` | Sidebar color: `Red`, `Orange`, `Yellow`, `Green`, `Blue`, `Purple`, `Pink`, `Gray` or `None`. Falls back to `None`. | @@ -80,9 +81,9 @@ editor afterwards. ## What the user sees -The review sheet shows the type, host and port, database, username, SSH auth method, SSL mode, color, +The review sheet shows the type, host and port or socket, database, username, SSH auth method, SSL mode, color, icon, tag, group, driver fields, and startup SQL. Only the name is editable. **Add Connection** saves it; -when a connection with the same name, host, port and type is already there, the sheet warns +when a connection with the same name, host and port or socket, and type is already there, the sheet warns *"A connection with this name, host, and type already exists."* and the button becomes **Add as Copy**.