diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe3868f2a..5c51aa228c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off. - **Local only** connections taking edits and deletions made on another device. - Undo and Redo in a tab with unsaved edits replaying another tab's changes against the wrong rows. +- Client certificate and key dropped by SSH, Cloudflare, SOCKS, Tunnel Command and Cloud SQL tunnels. +- SOCKS proxy dialing the hidden Host instead of the first host-list entry. +- "SSH password rejected" shown for a Password-auth SSH tunnel with no saved password. +- Literal backticks in cloudflared, cloud-sql-proxy, SSH config, remote command and dump tool install messages. +- Tunnel command preview showing port 0 or the wrong host when Port is blank or the connection uses a host list. +- SSH tab host-list warning naming replica set failover for Redis and Kafka, and implying Sentinel works through a tunnel. ## [0.76.1] - 2026-09-29 diff --git a/TablePro/Core/CloudSQL/CloudSQLProxyError.swift b/TablePro/Core/CloudSQL/CloudSQLProxyError.swift index 6a2eececfb..d905168e54 100644 --- a/TablePro/Core/CloudSQL/CloudSQLProxyError.swift +++ b/TablePro/Core/CloudSQL/CloudSQLProxyError.swift @@ -16,7 +16,7 @@ enum CloudSQLProxyError: Error, LocalizedError, Equatable { var errorDescription: String? { switch self { case .binaryNotFound: - return String(localized: "cloud-sql-proxy was not found. Install it with `brew install cloud-sql-proxy`, or download it in the Cloud SQL Auth Proxy settings.") + return String(localized: "cloud-sql-proxy was not found. Install it with “brew install cloud-sql-proxy”, or download it in the Cloud SQL Auth Proxy settings.") case .noAvailablePort: return String(localized: "No available local port for the Cloud SQL Auth Proxy.") case .invalidInstanceConnectionName: diff --git a/TablePro/Core/Cloudflare/CloudflareTunnelError.swift b/TablePro/Core/Cloudflare/CloudflareTunnelError.swift index 06d834c3cf..d6bf095ef1 100644 --- a/TablePro/Core/Cloudflare/CloudflareTunnelError.swift +++ b/TablePro/Core/Cloudflare/CloudflareTunnelError.swift @@ -17,7 +17,7 @@ enum CloudflareTunnelError: Error, LocalizedError, Equatable { var errorDescription: String? { switch self { case .binaryNotFound: - return String(localized: "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings.") + return String(localized: "cloudflared was not found. Install it with “brew install cloudflared”, or set its path in the connection's Cloudflare Tunnel settings.") case .noAvailablePort: return String(localized: "No available local port for the Cloudflare tunnel.") case .startupFailed(let stderrTail): diff --git a/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift b/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift index c25cd73dcf..eab4c3ea21 100644 --- a/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift +++ b/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift @@ -15,12 +15,13 @@ extension DatabaseManager { ? nil : connectionStorage.loadSOCKSProxyPassword(for: connection.id) + let endpoint = connection.tunnelForwardEndpoint let tunnelPort = try await SOCKSProxyManager.shared.createTunnel( connectionId: connection.id, config: config, password: password, - targetHost: connection.host, - targetPort: connection.port + targetHost: endpoint.host, + targetPort: endpoint.port ) return tunneledConnection(from: connection, localPort: tunnelPort) diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 319d7072c9..69e0b8e541 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -12,7 +12,7 @@ import TableProPluginKit extension DatabaseManager { /// Rewrite a connection to point at the local tunnel endpoint. A 127.0.0.1 /// certificate can't satisfy hostname verification, so verify modes drop to - /// `.required` while keeping encryption; cert paths are cleared and the pre-tunnel + /// `.required` while keeping encryption and the CA path is cleared. The pre-tunnel /// endpoint is recorded for the callers that must name the real server rather than /// the local forward. A tunnel forwards a single local port, so MongoDB's /// seed list is collapsed to that endpoint and a direct connection is forced, @@ -42,8 +42,6 @@ extension DatabaseManager { tunnelSSL.mode = .required } tunnelSSL.caCertificatePath = "" - tunnelSSL.clientCertificatePath = "" - tunnelSSL.clientKeyPath = "" } var effectiveFields = connection.additionalFields diff --git a/TablePro/Core/Database/NativeDumpRegistry.swift b/TablePro/Core/Database/NativeDumpRegistry.swift index 2be8264d80..099279a3bb 100644 --- a/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/TablePro/Core/Database/NativeDumpRegistry.swift @@ -86,7 +86,7 @@ enum NativeDumpRegistry { NativeDumpDescriptor.CommandLineTool( backupBinaries: ["pg_dump"], restoreBinaries: ["pg_restore"], - installHint: String(localized: "Install it with `brew install libpq` and link it."), + installHint: String(localized: "Install it with “brew install libpq” and link it."), backupDelivery: .toolWritesFile, restoreDelivery: .toolWritesFile, restoreExitPolicy: .toleratesUnrecognizedSessionSettings, @@ -184,7 +184,7 @@ enum NativeDumpRegistry { NativeDumpDescriptor.CommandLineTool( backupBinaries: ["mysqldump", "mariadb-dump"], restoreBinaries: ["mysql", "mariadb"], - installHint: String(localized: "Install it with `brew install mysql-client` and link it."), + installHint: String(localized: "Install it with “brew install mysql-client” and link it."), backupDelivery: .standardOutput, restoreDelivery: .standardOutput, identifyExecutable: { name, path in @@ -266,7 +266,7 @@ enum NativeDumpRegistry { NativeDumpDescriptor.CommandLineTool( backupBinaries: ["mongodump"], restoreBinaries: ["mongorestore"], - installHint: String(localized: "Install it with `brew install mongodb-database-tools`."), + installHint: String(localized: "Install it with “brew install mongodb-database-tools”."), backupDelivery: .toolWritesFile, restoreDelivery: .toolWritesFile, needsCredentialsFile: true, @@ -401,7 +401,7 @@ enum NativeDumpRegistry { NativeDumpDescriptor.CommandLineTool( backupBinaries: ["sqlite3"], restoreBinaries: ["sqlite3"], - installHint: String(localized: "Install it with `brew install sqlite` and link it."), + installHint: String(localized: "Install it with “brew install sqlite” and link it."), backupDelivery: .standardOutput, restoreDelivery: .standardOutput, backupArguments: { request, _ in diff --git a/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift b/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift index 332ec4bc45..ec546c2081 100644 --- a/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift +++ b/TablePro/Core/Database/PostgreSQLDumpToolCompatibility.swift @@ -62,7 +62,7 @@ enum PostgreSQLDumpToolCompatibility { } private static func installHint(newestUsableTool: PostgreSQLServerVersion?) -> String { - guard let newestUsableTool else { return String(localized: "Install it with `brew install libpq`.") } + guard let newestUsableTool else { return String(localized: "Install it with “brew install libpq”.") } guard newestUsableTool >= PostgreSQLServerVersion(number: 100_000) else { return String( format: String(localized: "Install one from PostgreSQL %@ or earlier."), @@ -70,7 +70,7 @@ enum PostgreSQLDumpToolCompatibility { ) } return String( - format: String(localized: "Install one with `brew install postgresql@%@`."), + format: String(localized: "Install one with “brew install postgresql@%@”."), newestUsableTool.majorReleaseName ) } diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 41acd53f5e..99952790d1 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -188,9 +188,7 @@ internal enum LibSSH2TunnelFactory { } if resolvedPrimary.username.isEmpty { - throw SSHTunnelError.tunnelCreationFailed( - "SSH username not set. Add it to the form or set `User` for `\(config.host)` in ~/.ssh/config." - ) + throw SSHTunnelError.usernameMissing(host: config.host) } let firstHop = resolvedJumps.first ?? resolvedPrimary @@ -527,7 +525,7 @@ internal enum LibSSH2TunnelFactory { // Sequel Ace's behavior. guard let sshPassword = credentials.sshPassword else { logger.error("SSH password is nil (Keychain lookup may have failed) for \(resolved.host)") - throw SSHTunnelError.authenticationFailed(reason: .password) + throw SSHTunnelError.authenticationFailed(reason: .passwordMissing) } return CompositeAuthenticator(authenticators: [ PasswordAuthenticator(password: sshPassword), diff --git a/TablePro/Core/SSH/SFTPError.swift b/TablePro/Core/SSH/SFTPError.swift index e80dae1505..7a4f774c63 100644 --- a/TablePro/Core/SSH/SFTPError.swift +++ b/TablePro/Core/SSH/SFTPError.swift @@ -38,7 +38,7 @@ enum SFTPError: LocalizedError, Equatable { ) case .remoteCommandFailed(let command, let status, let output): return String( - format: String(localized: "`%1$@` exited with status %2$d on the server: %3$@"), + format: String(localized: "“%1$@” exited with status %2$d on the server: %3$@"), command, status, output ) case .cancelled: diff --git a/TablePro/Core/SSH/SSHConfigTokens.swift b/TablePro/Core/SSH/SSHConfigTokens.swift index cb0f05f10e..837ed67d00 100644 --- a/TablePro/Core/SSH/SSHConfigTokens.swift +++ b/TablePro/Core/SSH/SSHConfigTokens.swift @@ -69,24 +69,24 @@ enum SSHTokenExpansionError: Error, Hashable, Sendable { switch self { case .unsupportedToken(let keyword, let token): return String( - format: String(localized: "`%@` in ~/.ssh/config does not accept the token %@."), + format: String(localized: "The %@ line in ~/.ssh/config does not accept the token %@."), keyword, token ) case .danglingPercent(let keyword): return String( - format: String(localized: "`%@` in ~/.ssh/config ends in a lone %%. Write %%%% for a literal percent sign."), + format: String(localized: "The %@ line in ~/.ssh/config ends in a lone %%. Write %%%% for a literal percent sign."), keyword ) case .undefinedEnvironmentVariable(let keyword, let name): return String( - format: String(localized: "`%@` in ~/.ssh/config uses ${%@}, which is not set in the environment."), + format: String(localized: "The %@ line in ~/.ssh/config uses ${%@}, which is not set in the environment."), keyword, name ) case .malformedEnvironmentReference(let keyword): return String( - format: String(localized: "`%@` in ~/.ssh/config has a ${ with no closing brace."), + format: String(localized: "The %@ line in ~/.ssh/config has a ${ with no closing brace."), keyword ) } diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 9a25f44a9f..63fa0d36e5 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -15,6 +15,7 @@ import TableProSSHTransport /// the catch-all "credentials or private key" message. enum AuthFailureReason: Sendable, Hashable, CaseIterable { case password + case passwordMissing case verificationCode case privateKey case agentUnavailable(AgentSocketOrigin) @@ -32,7 +33,7 @@ enum AuthFailureReason: Sendable, Hashable, CaseIterable { /// Hand-written because the two agent cases carry the socket source they are about, which /// stops `CaseIterable` synthesising this. static var allCases: [AuthFailureReason] { - [.password, .verificationCode, .privateKey] + [.password, .passwordMissing, .verificationCode, .privateKey] + AgentSocketOrigin.allCases.map(AuthFailureReason.agentUnavailable) + AgentSocketOrigin.allCases.map(AuthFailureReason.agentNoIdentities) + AgentSocketOrigin.allCases.map(AuthFailureReason.agentNoMatchingIdentity) @@ -82,6 +83,7 @@ enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case configExpansionFailed(String) case tunnelAlreadyExists(UUID) case noAvailablePort + case usernameMissing(host: String) case authenticationFailed(reason: AuthFailureReason) case connectionTimeout case hostKeyVerificationFailed @@ -100,10 +102,17 @@ enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { return String(format: String(localized: "SSH tunnel already exists for connection: %@"), id.uuidString) case .noAvailablePort: return String(localized: "No available local port for SSH tunnel") + case .usernameMissing(let host): + return String( + format: String(localized: "SSH username not set. Add it to the form, or add a User line for %@ in ~/.ssh/config."), + host + ) case .authenticationFailed(let reason): switch reason { case .password: return String(localized: "SSH password rejected. Check the password and try again.") + case .passwordMissing: + return String(localized: "No SSH password was found for this connection. Enter it in the SSH settings and try again.") case .verificationCode: return String(localized: "Verification code rejected. Get a new code from your authenticator app and try again.") case .privateKey: diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4732c63b49..d048f018c5 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1181,6 +1181,40 @@ } } }, + "“%1$@” exited with status %2$d on the server: %3$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버에서 “%1$@”이(가) 상태 %2$d(으)로 종료되었습니다: %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@” sunucuda %2$d durumuyla sonlandı: %3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@” kết thúc với mã trạng thái %2$d trên máy chủ: %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@” 在服务器上以状态 %2$d 退出:%3$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@” 在伺服器上以狀態 %2$d 結束:%3$@" + } + } + } + }, "Couldn't Save Files" : { "localizations" : { "ko" : { @@ -9887,66 +9921,6 @@ } } }, - "`%@` in ~/.ssh/config does not accept the token %@." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "`%1$@` in ~/.ssh/config does not accept the token %2$@." - } - } - } - }, - "`%@` in ~/.ssh/config ends in a lone %%. Write %%%% for a literal percent sign." : { - - }, - "`%@` in ~/.ssh/config has a ${ with no closing brace." : { - - }, - "`%@` in ~/.ssh/config uses ${%@}, which is not set in the environment." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "`%1$@` in ~/.ssh/config uses ${%2$@}, which is not set in the environment." - } - } - } - }, - "`%1$@` exited with status %2$d on the server: %3$@" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "서버에서 `%1$@`이(가) 상태 %2$d(으)로 종료되었습니다: %3$@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "`%1$@` sunucuda %2$d durumuyla sonlandı: %3$@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "`%1$@` kết thúc với mã trạng thái %2$d trên máy chủ: %3$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "`%1$@` 在服务器上以状态 %2$d 退出:%3$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "`%1$@` 在伺服器上以狀態 %2$d 結束:%3$@" - } - } - } - }, "^[%lld connection](inflect: true) use this profile. They keep these credentials as their own." : { }, @@ -34184,70 +34158,70 @@ "Cloud Storage URI" : { }, - "cloud-sql-proxy not found. Install it with `brew install cloud-sql-proxy`, download it above, or choose the binary." : { + "cloud-sql-proxy not found. Install it with “brew install cloud-sql-proxy”, download it above, or choose the binary." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "cloud-sql-proxy를 찾을 수 없습니다. `brew install cloud-sql-proxy`로 설치하거나 위에서 다운로드하거나 바이너리를 선택하십시오." + "value" : "cloud-sql-proxy를 찾을 수 없습니다. “brew install cloud-sql-proxy”로 설치하거나 위에서 다운로드하거나 바이너리를 선택하십시오." } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "cloud-sql-proxy bulunamadı. `brew install cloud-sql-proxy` ile yükleyin, yukarıdan indirin veya çalıştırılabilir dosyayı seçin." + "value" : "cloud-sql-proxy bulunamadı. “brew install cloud-sql-proxy” ile yükleyin, yukarıdan indirin veya çalıştırılabilir dosyayı seçin." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy cloud-sql-proxy. Cài bằng `brew install cloud-sql-proxy`, tải xuống ở trên, hoặc chọn tệp thực thi." + "value" : "Không tìm thấy cloud-sql-proxy. Cài bằng “brew install cloud-sql-proxy”, tải xuống ở trên, hoặc chọn tệp thực thi." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到 cloud-sql-proxy。可用 `brew install cloud-sql-proxy` 安装、在上方下载,或直接选择可执行文件。" + "value" : "未找到 cloud-sql-proxy。可用 “brew install cloud-sql-proxy” 安装、在上方下载,或直接选择可执行文件。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到 cloud-sql-proxy。可用 `brew install cloud-sql-proxy` 安裝、在上方下載,或直接選擇執行檔。" + "value" : "找不到 cloud-sql-proxy。可用 “brew install cloud-sql-proxy” 安裝、在上方下載,或直接選擇執行檔。" } } } }, - "cloud-sql-proxy was not found. Install it with `brew install cloud-sql-proxy`, or download it in the Cloud SQL Auth Proxy settings." : { + "cloud-sql-proxy was not found. Install it with “brew install cloud-sql-proxy”, or download it in the Cloud SQL Auth Proxy settings." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "cloud-sql-proxy를 찾을 수 없습니다. `brew install cloud-sql-proxy`로 설치하거나 Cloud SQL Auth Proxy 설정에서 다운로드하십시오." + "value" : "cloud-sql-proxy를 찾을 수 없습니다. “brew install cloud-sql-proxy”로 설치하거나 Cloud SQL Auth Proxy 설정에서 다운로드하십시오." } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "cloud-sql-proxy bulunamadı. `brew install cloud-sql-proxy` ile yükleyin veya Cloud SQL Auth Proxy ayarlarından indirin." + "value" : "cloud-sql-proxy bulunamadı. “brew install cloud-sql-proxy” ile yükleyin veya Cloud SQL Auth Proxy ayarlarından indirin." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy cloud-sql-proxy. Cài bằng `brew install cloud-sql-proxy`, hoặc tải xuống trong cài đặt Cloud SQL Auth Proxy." + "value" : "Không tìm thấy cloud-sql-proxy. Cài bằng “brew install cloud-sql-proxy”, hoặc tải xuống trong cài đặt Cloud SQL Auth Proxy." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到 cloud-sql-proxy。可用 `brew install cloud-sql-proxy` 安装,或在 Cloud SQL Auth Proxy 设置中下载。" + "value" : "未找到 cloud-sql-proxy。可用 “brew install cloud-sql-proxy” 安装,或在 Cloud SQL Auth Proxy 设置中下载。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到 cloud-sql-proxy。可用 `brew install cloud-sql-proxy` 安裝,或在 Cloud SQL Auth Proxy 設定中下載。" + "value" : "找不到 cloud-sql-proxy。可用 “brew install cloud-sql-proxy” 安裝,或在 Cloud SQL Auth Proxy 設定中下載。" } } } @@ -34422,104 +34396,104 @@ } } }, - "cloudflared failed to start." : { + "cloudflared not found. Install it with “brew install cloudflared”, or choose the binary above." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared를 시작하지 못했습니다." + "value" : "cloudflared를 찾을 수 없습니다. “brew install cloudflared”로 설치하거나 위에서 바이너리를 선택하십시오." } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared başlatılamadı." + "value" : "cloudflared bulunamadı. “brew install cloudflared” ile yükleyin veya yukarıdan ikili dosyayı seçin." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared không khởi động được." + "value" : "Không tìm thấy cloudflared. Cài đặt bằng “brew install cloudflared”, hoặc chọn tệp thực thi ở trên." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared启动失败。" + "value" : "未找到cloudflared。请使用“brew install cloudflared”安装,或在上方选择其二进制文件。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared 啟動失敗。" + "value" : "找不到 cloudflared。請使用 “brew install cloudflared” 安裝,或在上方選擇其二進位檔。" } } } }, - "cloudflared not found. Install it with `brew install cloudflared`, or choose the binary above." : { + "cloudflared was not found. Install it with “brew install cloudflared”, or set its path in the connection's Cloudflare Tunnel settings." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared를 찾을 수 없습니다. `brew install cloudflared`로 설치하거나 위에서 바이너리를 선택하십시오." + "value" : "cloudflared를 찾을 수 없습니다. “brew install cloudflared”로 설치하거나 연결의 Cloudflare Tunnel 설정에서 경로를 지정하십시오." } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared bulunamadı. `brew install cloudflared` ile yükleyin veya yukarıdan ikili dosyayı seçin." + "value" : "cloudflared bulunamadı. “brew install cloudflared” ile yükleyin veya bağlantının Cloudflare Tüneli ayarlarında yolunu belirtin." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy cloudflared. Cài đặt bằng `brew install cloudflared`, hoặc chọn tệp thực thi ở trên." + "value" : "Không tìm thấy cloudflared. Cài đặt bằng “brew install cloudflared”, hoặc đặt đường dẫn trong cài đặt Cloudflare Tunnel của kết nối." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到cloudflared。请使用`brew install cloudflared`安装,或在上方选择其二进制文件。" + "value" : "未找到cloudflared。请使用“brew install cloudflared”安装,或在该连接的Cloudflare隧道设置中设置其路径。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到 cloudflared。請使用 `brew install cloudflared` 安裝,或在上方選擇其二進位檔。" + "value" : "找不到 cloudflared。請使用 “brew install cloudflared” 安裝,或在該連線的 Cloudflare 隧道設定中設定其路徑。" } } } }, - "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings." : { + "cloudflared failed to start." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared를 찾을 수 없습니다. `brew install cloudflared`로 설치하거나 연결의 Cloudflare Tunnel 설정에서 경로를 지정하십시오." + "value" : "cloudflared를 시작하지 못했습니다." } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "cloudflared bulunamadı. `brew install cloudflared` ile yükleyin veya bağlantının Cloudflare Tüneli ayarlarında yolunu belirtin." + "value" : "cloudflared başlatılamadı." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy cloudflared. Cài đặt bằng `brew install cloudflared`, hoặc đặt đường dẫn trong cài đặt Cloudflare Tunnel của kết nối." + "value" : "cloudflared không khởi động được." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到cloudflared。请使用`brew install cloudflared`安装,或在该连接的Cloudflare隧道设置中设置其路径。" + "value" : "cloudflared启动失败。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到 cloudflared。請使用 `brew install cloudflared` 安裝,或在該連線的 Cloudflare 隧道設定中設定其路徑。" + "value" : "cloudflared 啟動失敗。" } } } @@ -80579,25 +80553,25 @@ } } }, - "Install it with `brew install libpq` and link it." : { + "Install it with “brew install libpq” and link it." : { }, - "Install it with `brew install libpq`." : { + "Install it with “brew install libpq”." : { }, - "Install it with `brew install mongodb-database-tools`." : { + "Install it with “brew install mongodb-database-tools”." : { }, - "Install it with `brew install mysql-client` and link it." : { + "Install it with “brew install mysql-client” and link it." : { }, - "Install it with `brew install sqlite` and link it." : { + "Install it with “brew install sqlite” and link it." : { }, "Install one from PostgreSQL %@ or earlier." : { }, - "Install one with `brew install postgresql@%@`." : { + "Install one with “brew install postgresql@%@”." : { }, "Install Plugin" : { @@ -102790,6 +102764,9 @@ }, "No SSH agent answered on the socket from %@. Check that agent is running." : { + }, + "No SSH password was found for this connection. Enter it in the SSH settings and try again." : { + }, "No SSH servers yet." : { @@ -147692,6 +147669,9 @@ } } } + }, + "SSH username not set. Add it to the form, or add a User line for %@ in ~/.ssh/config." : { + }, "SSH Verification Rejected" : { "localizations" : { @@ -154750,9 +154730,18 @@ } } } + }, + "TablePro connects to the first broker over a tunnel. Partitions led by other brokers cannot be read." : { + }, "TablePro connects to the first host over a tunnel. Replica set failover is not available." : { + }, + "TablePro connects to the first host over a tunnel. The other hosts are not used." : { + + }, + "TablePro connects to the first node over a tunnel, as a single server. Keys on other nodes cannot be reached." : { + }, "TablePro could not do this for you. Run this in Terminal instead:" : { "localizations" : { @@ -157641,6 +157630,32 @@ } } }, + "The %@ line in ~/.ssh/config does not accept the token %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The %1$@ line in ~/.ssh/config does not accept the token %2$@." + } + } + } + }, + "The %@ line in ~/.ssh/config ends in a lone %%. Write %%%% for a literal percent sign." : { + + }, + "The %@ line in ~/.ssh/config has a ${ with no closing brace." : { + + }, + "The %@ line in ~/.ssh/config uses ${%@}, which is not set in the environment." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The %1$@ line in ~/.ssh/config uses ${%2$@}, which is not set in the environment." + } + } + } + }, "The %@ plugin could not be loaded." : { }, @@ -182640,6 +182655,9 @@ }, "Sentinel %@ sent a reply TablePro could not read (%@)." : { + }, + "Sentinel mode cannot run through a tunnel. Set Connection Mode to Standalone and enter a data node, or turn the tunnel off." : { + }, "Sentinel %@ refused the request: %@" : { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift index 9168abc977..75235172e5 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift @@ -88,6 +88,15 @@ extension ConnectionFormCoordinator { services.pluginManager.supportsSSL(for: network.type) } + internal var tunnelCommandPreview: String? { + let endpoint = buildEdits().applied(to: DatabaseConnection(name: "")).tunnelForwardEndpoint + return TunnelCommandBuilder.previewCommand( + for: tunnelCommand.state.buildConfig(), + remoteHost: endpoint.host, + remotePort: endpoint.port + ) + } + /// Collapses whatever the stored connection carries onto a single transport. /// /// Assigning through the setter is what does the work: a connection saved by an older build diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift index 15ea644d3a..0a67912bfc 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/CloudSQLProxyTransportSections.swift @@ -147,10 +147,7 @@ struct CloudSQLProxyTransportSections: View { LabeledContent(String(localized: "Detected"), value: resolved) .foregroundStyle(.secondary) } else if viewModel.didResolveBinary { - Label( - String(localized: "cloud-sql-proxy not found. Install it with `brew install cloud-sql-proxy`, download it above, or choose the binary."), - systemImage: "exclamationmark.triangle.fill" - ) + Label(viewModel.binaryNotFoundMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) .textSelection(.enabled) } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift index 20e6054148..116c185028 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/CloudflareTransportSections.swift @@ -106,10 +106,7 @@ struct CloudflareTransportSections: View { LabeledContent(String(localized: "Detected"), value: resolved) .foregroundStyle(.secondary) } else if viewModel.didResolveBinary { - Label( - String(localized: "cloudflared not found. Install it with `brew install cloudflared`, or choose the binary above."), - systemImage: "exclamationmark.triangle.fill" - ) + Label(viewModel.binaryNotFoundMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) .textSelection(.enabled) } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift index 701fdd6860..52c464a9fb 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift @@ -29,11 +29,8 @@ struct SSHTransportSections: View { text: $coordinator.network.sshForwardUnixSocketPath, prompt: Text(verbatim: coordinator.network.socketPathPrompt) ) - if coordinator.network.hasHostListField, replicaSetHostsAreListed { - Label( - String(localized: "TablePro connects to the first host over a tunnel. Replica set failover is not available."), - systemImage: "exclamationmark.triangle" - ) + if let caption = coordinator.network.tunnelHostListCaption { + Label(caption, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.secondary) } @@ -85,10 +82,6 @@ struct SSHTransportSections: View { .font(.caption) .foregroundStyle(tint) } - - private var replicaSetHostsAreListed: Bool { - coordinator.network.firstHostListValue.contains(",") - } } /// Points a file-backed connection at a database file on an SSH server. diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift index 4e14a63fea..4d1ac80635 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift @@ -8,8 +8,6 @@ import SwiftUI struct TunnelCommandTransportSections: View { @ObservedObject var coordinator: ConnectionFormCoordinator - private var viewModel: TunnelCommandPaneViewModel { coordinator.tunnelCommand } - var body: some View { methodSection methodFieldsSection @@ -136,10 +134,7 @@ struct TunnelCommandTransportSections: View { @ViewBuilder private var previewSection: some View { - if let preview = viewModel.previewCommand( - remoteHost: coordinator.network.host, - remotePort: Int(coordinator.network.port) ?? 0 - ) { + if let preview = coordinator.tunnelCommandPreview { Section { Text(verbatim: preview) .font(.system(.caption, design: .monospaced)) diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift index 376959d1ee..dbbb9dcef5 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift @@ -59,6 +59,12 @@ final class CloudSQLProxyPaneViewModel: ObservableObject { storage.saveCloudSQLProxyServiceAccountKey(state.serviceAccountKeyJSON, for: connectionId) } + internal var binaryNotFoundMessage: String { + String( + localized: "cloud-sql-proxy not found. Install it with “brew install cloud-sql-proxy”, download it above, or choose the binary." + ) + } + func resolveBinary() { Task { let found = await Task.detached { CLIExecutableFinder.findExecutable("cloud-sql-proxy") }.value diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift index d00ef6d614..44b2e04519 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -63,6 +63,10 @@ final class CloudflareTunnelPaneViewModel: ObservableObject { storage.saveCloudflareTokenSecret(state.serviceTokenSecret, for: connectionId) } + internal var binaryNotFoundMessage: String { + String(localized: "cloudflared not found. Install it with “brew install cloudflared”, or choose the binary above.") + } + func resolveBinary() { Task { let path = await Task.detached { CLIExecutableFinder.findExecutable("cloudflared") }.value diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index 5eee0cd9ae..112d687326 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -45,13 +45,36 @@ final class NetworkPaneViewModel: ObservableObject { return port == 0 ? "" : String(port) } - var firstHostListValue: String { - let fieldId = connectionFields.first { field in + private var firstVisibleHostListFieldId: String? { + connectionFields.first { field in guard case .hostList = field.fieldType else { return false } return isFieldVisible(field) }?.id - guard let fieldId else { return "" } - return additionalFieldValues[fieldId] ?? "" + } + + internal var tunnelHostListCaption: String? { + guard let fieldId = firstVisibleHostListFieldId else { return nil } + if fieldId == "redisSentinelHosts" { + return String( + localized: "Sentinel mode cannot run through a tunnel. Set Connection Mode to Standalone and enter a data node, or turn the tunnel off." + ) + } + if fieldId == "redisClusterHosts" { + return String( + localized: "TablePro connects to the first node over a tunnel, as a single server. Keys on other nodes cannot be reached." + ) + } + guard (additionalFieldValues[fieldId] ?? "").contains(",") else { return nil } + switch fieldId { + case "mongoHosts": + return String(localized: "TablePro connects to the first host over a tunnel. Replica set failover is not available.") + case "kafkaBootstrapServers": + return String( + localized: "TablePro connects to the first broker over a tunnel. Partitions led by other brokers cannot be read." + ) + default: + return String(localized: "TablePro connects to the first host over a tunnel. The other hosts are not used.") + } } var socketPathPrompt: String { diff --git a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift index 2b37cbeb35..04aba72831 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/TunnelCommandPaneViewModel.swift @@ -17,14 +17,6 @@ final class TunnelCommandPaneViewModel: ObservableObject { return TunnelCommandBuilder.validationIssues(for: state.buildConfig()) } - func previewCommand(remoteHost: String, remotePort: Int) -> String? { - TunnelCommandBuilder.previewCommand( - for: state.buildConfig(), - remoteHost: remoteHost.isEmpty ? "localhost" : remoteHost, - remotePort: remotePort - ) - } - func load(from connection: DatabaseConnection) { state.load(from: connection) } diff --git a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift index 0b35fa182f..124b51f7d7 100644 --- a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift +++ b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift @@ -16,7 +16,7 @@ struct CloudSQLProxyModelTests { authMode: .serviceAccountKey, useIAMAuth: true, usePrivateIP: true, - localPort: 6543, + localPort: 6_543, binaryPath: "/opt/homebrew/bin/cloud-sql-proxy" ) @@ -50,7 +50,7 @@ struct CloudSQLProxyModelTests { let connection = DatabaseConnection( name: "Cloud SQL", host: "ignored", - port: 5432, + port: 5_432, type: .postgresql, cloudSQLProxyMode: .inline( CloudSQLProxyConfiguration(instanceConnectionName: "proj:region:inst", authMode: .applicationDefault) @@ -93,7 +93,7 @@ struct CloudSQLProxyModelTests { @Test("CloudSQLProxyPidRecord round-trips for the stale-PID sweep") func pidRecordRoundTrip() throws { - let records = [CloudSQLProxyPidRecord(pid: 4242, binaryPath: "/opt/homebrew/bin/cloud-sql-proxy")] + let records = [CloudSQLProxyPidRecord(pid: 4_242, binaryPath: "/opt/homebrew/bin/cloud-sql-proxy")] let data = try JSONEncoder().encode(records) let decoded = try JSONDecoder().decode([CloudSQLProxyPidRecord].self, from: data) #expect(decoded == records) @@ -111,4 +111,12 @@ struct CloudSQLProxyModelTests { #expect(!DatabaseType.sqlite.supportsCloudSQLProxy) #expect(!DatabaseType.mongodb.supportsCloudSQLProxy) } + + @Test("The cloud-sql-proxy not-found error is plain text that names the install command") + func binaryNotFoundIsPlainText() { + let description = CloudSQLProxyError.binaryNotFound.errorDescription ?? "" + + #expect(!description.contains("`")) + #expect(description.contains("brew install cloud-sql-proxy")) + } } diff --git a/TableProTests/CloudSQL/CloudSQLProxyPaneViewModelTests.swift b/TableProTests/CloudSQL/CloudSQLProxyPaneViewModelTests.swift index 6b300b9d52..97f1a220a9 100644 --- a/TableProTests/CloudSQL/CloudSQLProxyPaneViewModelTests.swift +++ b/TableProTests/CloudSQL/CloudSQLProxyPaneViewModelTests.swift @@ -55,4 +55,12 @@ struct CloudSQLProxyPaneViewModelTests { viewModel.state.serviceAccountKeyJSON = "{\"type\":\"service_account\"}" #expect(viewModel.validationIssues.isEmpty) } + + @Test("The not-found caption is plain text that names the install command") + func binaryNotFoundMessageIsPlainText() { + let message = CloudSQLProxyPaneViewModel().binaryNotFoundMessage + + #expect(!message.contains("`")) + #expect(message.contains("brew install cloud-sql-proxy")) + } } diff --git a/TableProTests/Cloudflare/CloudflareModelTests.swift b/TableProTests/Cloudflare/CloudflareModelTests.swift index f7cd2a803e..d8d6f330f9 100644 --- a/TableProTests/Cloudflare/CloudflareModelTests.swift +++ b/TableProTests/Cloudflare/CloudflareModelTests.swift @@ -13,7 +13,7 @@ struct CloudflareModelTests { func configurationRoundTrip() throws { let config = CloudflareConfiguration( accessHostname: "db.example.com", - localPort: 6543, + localPort: 6_543, authMethod: .serviceToken, exposeToLAN: true, binaryPath: "/opt/homebrew/bin/cloudflared" @@ -47,7 +47,7 @@ struct CloudflareModelTests { let connection = DatabaseConnection( name: "CF", host: "db.internal", - port: 5432, + port: 5_432, type: .postgresql, cloudflareTunnelMode: .inline(CloudflareConfiguration(accessHostname: "db.example.com", authMethod: .browserSSO)) ) @@ -73,9 +73,17 @@ struct CloudflareModelTests { @Test("CloudflaredPidRecord round-trips for the stale-PID sweep") func pidRecordRoundTrip() throws { - let records = [CloudflaredPidRecord(pid: 4242, binaryPath: "/opt/homebrew/bin/cloudflared")] + let records = [CloudflaredPidRecord(pid: 4_242, binaryPath: "/opt/homebrew/bin/cloudflared")] let data = try JSONEncoder().encode(records) let decoded = try JSONDecoder().decode([CloudflaredPidRecord].self, from: data) #expect(decoded == records) } + + @Test("The cloudflared not-found error is plain text that names the install command") + func binaryNotFoundIsPlainText() { + let description = CloudflareTunnelError.binaryNotFound.errorDescription ?? "" + + #expect(!description.contains("`")) + #expect(description.contains("brew install cloudflared")) + } } diff --git a/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift b/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift index 8bb0047a02..b6b9d7e3f9 100644 --- a/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift +++ b/TableProTests/Cloudflare/CloudflareTunnelPaneViewModelTests.swift @@ -52,4 +52,12 @@ struct CloudflareTunnelPaneViewModelTests { viewModel.state.authMethod = .browserSSO #expect(viewModel.validationIssues.isEmpty) } + + @Test("The not-found caption is plain text that names the install command") + func binaryNotFoundMessageIsPlainText() { + let message = CloudflareTunnelPaneViewModel().binaryNotFoundMessage + + #expect(!message.contains("`")) + #expect(message.contains("brew install cloudflared")) + } } diff --git a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift index a78e60624e..0edd544fc4 100644 --- a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -143,6 +143,75 @@ struct DatabaseManagerTunnelTests { #expect(tunneled.sslConfig.mode == .required) } + @Test("A TCP forward keeps the client certificate and key") + func tcpForwardKeepsClientIdentity() { + let connection = DatabaseConnection( + name: "pg", + host: "db.internal", + port: 5_432, + type: .postgresql, + sslConfig: SSLConfiguration( + mode: .required, + clientCertificatePath: "/certs/client.pem", + clientKeyPath: "/certs/client.key" + ) + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.sslConfig.mode == .required) + #expect(tunneled.sslConfig.clientCertificatePath == "/certs/client.pem") + #expect(tunneled.sslConfig.clientKeyPath == "/certs/client.key") + } + + @Test("A TCP forward relaxes Verify Identity and still keeps the client certificate and key") + func tcpForwardRelaxesVerificationAndKeepsClientIdentity() { + let connection = DatabaseConnection( + name: "mysql", + host: "db.internal", + port: 3_306, + type: .mysql, + sslConfig: SSLConfiguration( + mode: .verifyIdentity, + caCertificatePath: "/certs/ca.pem", + clientCertificatePath: "/certs/client.pem", + clientKeyPath: "/certs/client.key" + ) + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.sslConfig.mode == .required) + #expect(tunneled.sslConfig.clientCertificatePath == "/certs/client.pem") + #expect(tunneled.sslConfig.clientKeyPath == "/certs/client.key") + } + + @Test("A socket forward clears the client certificate and key along with TLS") + func socketForwardClearsClientIdentity() { + var connection = DatabaseConnection( + name: "socket", + host: "db.internal", + port: 5_432, + type: .postgresql, + sslConfig: SSLConfiguration( + mode: .required, + clientCertificatePath: "/certs/client.pem", + clientKeyPath: "/certs/client.key" + ) + ) + connection.sshForwardUnixSocketPath = "/var/run/postgresql/.s.PGSQL.5432" + + let tunneled = DatabaseManager.shared.tunneledConnection( + from: connection, + localPort: 62_000, + forwardsToUnixSocket: true + ) + + #expect(tunneled.sslConfig.mode == .disabled) + #expect(tunneled.sslConfig.clientCertificatePath.isEmpty) + #expect(tunneled.sslConfig.clientKeyPath.isEmpty) + } + @Test("The pre-tunnel endpoint is recorded for every tunneled connection") func tunnelRecordsPreTunnelEndpoint() { let connection = DatabaseConnection( diff --git a/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift b/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift index 813066c3ee..2a7e2a665c 100644 --- a/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift +++ b/TableProTests/Core/Database/PostgreSQLServerVersionTests.swift @@ -149,4 +149,17 @@ struct PostgreSQLDumpToolCompatibilityTests { #expect(message.contains("PostgreSQL 19 needs pg_dump 19 or later.")) #expect(message.contains("brew install libpq")) } + + @Test("Every refusal is plain text") + func refusalsArePlainText() throws { + let found = [PostgreSQLDumpToolCompatibility.Candidate(path: "/a", version: try version("17.11"))] + for server in ["9.1.24", "19.0", "7.4.30"] { + let message = PostgreSQLDumpToolCompatibility.refusal( + for: try version(server), + found: found, + toolName: "pg_dump" + ) + #expect(!message.contains("`"), "\(server)") + } + } } diff --git a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift index 6dfb4718ea..912674daf8 100644 --- a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift +++ b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift @@ -214,9 +214,9 @@ struct BuildAuthenticatorTests { #expect(authenticator is NoneAuthenticator) } - @Test("Password auth method with no password throws before any libssh2 call") + @Test("Password auth method with no password reports it missing before any libssh2 call") func passwordWithoutCredentialThrows() { - #expect(throws: SSHTunnelError.authenticationFailed(reason: .password)) { + #expect(throws: SSHTunnelError.authenticationFailed(reason: .passwordMissing)) { try LibSSH2TunnelFactory.buildAuthenticator( config: config(authMethod: .password, totpMode: .none), resolved: resolved(), @@ -225,6 +225,16 @@ struct BuildAuthenticatorTests { } } + @Test("A missing password and a refused password read differently") + func missingPasswordIsNotReportedAsRejected() { + let missing = SSHTunnelError.authenticationFailed(reason: .passwordMissing).errorDescription ?? "" + let rejected = SSHTunnelError.authenticationFailed(reason: .password).errorDescription ?? "" + + #expect(!missing.isEmpty) + #expect(missing != rejected) + #expect(!missing.localizedCaseInsensitiveContains("rejected")) + } + @Test("Keyboard-Interactive auth method passes the password through directly") func keyboardInteractivePassesPassword() throws { let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( diff --git a/TableProTests/Core/SSH/SSHConfigTokensTests.swift b/TableProTests/Core/SSH/SSHConfigTokensTests.swift index 30397d1fc8..8d478e3411 100644 --- a/TableProTests/Core/SSH/SSHConfigTokensTests.swift +++ b/TableProTests/Core/SSH/SSHConfigTokensTests.swift @@ -221,6 +221,21 @@ struct SSHConfigTokensTests { _ = gethostname(&buffer, buffer.count) #expect(SSHTokenContext.systemHostname() == String(cString: buffer)) } + + @Test("Every expansion failure is plain text that names the keyword") + func expansionFailuresArePlainText() { + let failures: [SSHTokenExpansionError] = [ + .unsupportedToken(keyword: "IdentityAgent", token: "%r"), + .danglingPercent(keyword: "IdentityAgent"), + .undefinedEnvironmentVariable(keyword: "IdentityAgent", name: "NOPE"), + .malformedEnvironmentReference(keyword: "IdentityAgent") + ] + + for failure in failures { + #expect(!failure.explanation.contains("`"), "\(failure)") + #expect(failure.explanation.contains("IdentityAgent"), "\(failure)") + } + } } struct SSHPathExpansionTests { diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 7e023769a0..e0ddeef1a5 100644 --- a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -6,8 +6,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing struct SSHTunnelErrorTests { @@ -98,4 +98,26 @@ struct SSHTunnelErrorTests { #expect(error.errorDescription?.contains("db.internal:3306") == true) #expect(error.errorDescription?.contains("6") == true) } + + @Test("A missing SSH username is plain text that names the host") + func usernameMissingDescription() { + let description = SSHTunnelError.usernameMissing(host: "bastion.example").errorDescription ?? "" + + #expect(!description.contains("`")) + #expect(description.contains("bastion.example")) + #expect(description.contains("~/.ssh/config")) + } + + @Test("A failed remote command is plain text that names the command") + func remoteCommandFailedIsPlainText() { + let description = SFTPError.remoteCommandFailed( + command: "VACUUM INTO", + status: 1, + output: "disk I/O error" + ).errorDescription ?? "" + + #expect(!description.contains("`")) + #expect(description.contains("VACUUM INTO")) + #expect(description.contains("disk I/O error")) + } } diff --git a/TableProTests/Database/NativeDumpRegistryTests.swift b/TableProTests/Database/NativeDumpRegistryTests.swift index 01b3249900..2febb996a6 100644 --- a/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/TableProTests/Database/NativeDumpRegistryTests.swift @@ -414,4 +414,13 @@ struct NativeDumpRegistryTests { #expect(NativeDumpService.sizeQuery(for: .mongodb) == nil) #expect(NativeDumpService.sizeQuery(for: .sqlite) == nil) } + + @Test("Every install hint is plain text") + func installHintsArePlainText() throws { + for type in [DatabaseType.postgresql, .redshift, .mysql, .mongodb, .sqlite] { + let tool = try #require(NativeDumpRegistry.descriptor(for: type)?.commandLineTool) + #expect(!tool.installHint.contains("`"), "\(type.rawValue)") + #expect(tool.installHint.contains("brew install"), "\(type.rawValue)") + } + } } diff --git a/TableProTests/SOCKS/SOCKSProxyTargetTests.swift b/TableProTests/SOCKS/SOCKSProxyTargetTests.swift new file mode 100644 index 0000000000..790edc3c45 --- /dev/null +++ b/TableProTests/SOCKS/SOCKSProxyTargetTests.swift @@ -0,0 +1,62 @@ +// +// SOCKSProxyTargetTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +struct SOCKSProxyTargetTests { + private func dialedTarget(for connection: DatabaseConnection) async throws -> FakeSOCKS5Server.ConnectRequest { + let server = FakeSOCKS5Server(behavior: .echo) + try await server.start() + defer { server.stop() } + + var proxied = connection + proxied.socksProxyMode = .inline(SOCKSProxyConfiguration(host: "127.0.0.1", port: server.port)) + + _ = try await DatabaseManager.shared.buildSOCKSProxyEffectiveConnection(for: proxied) + try await SOCKSProxyManager.shared.closeTunnel(connectionId: proxied.id) + + return try #require(server.capturedConnectRequest) + } + + @Test("A host-list connection dials its first listed node, not the hidden Host") + func hostListConnectionDialsFirstListedNode() async throws { + let connection = DatabaseConnection( + name: "cluster", + host: "stale.example", + port: 6_379, + type: .redis, + additionalFields: [ + "redisMode": "cluster", + "redisClusterHosts": "a.example:7000,b.example:7001" + ] + ) + + let request = try await dialedTarget(for: connection) + + #expect(request.address == Data("a.example".utf8)) + #expect(request.port == 7_000) + #expect(connection.tunnelForwardEndpoint.host == "a.example") + #expect(connection.tunnelForwardEndpoint.port == 7_000) + } + + @Test("A connection without a host list dials its Host and Port") + func singleHostConnectionDialsHostAndPort() async throws { + let connection = DatabaseConnection( + name: "pg", + host: "db.internal.example", + port: 5_432, + type: .postgresql + ) + + let request = try await dialedTarget(for: connection) + + #expect(request.address == Data("db.internal.example".utf8)) + #expect(request.port == 5_432) + } +} diff --git a/TableProTests/TunnelCommand/TunnelCommandPreviewTests.swift b/TableProTests/TunnelCommand/TunnelCommandPreviewTests.swift new file mode 100644 index 0000000000..545402a045 --- /dev/null +++ b/TableProTests/TunnelCommand/TunnelCommandPreviewTests.swift @@ -0,0 +1,46 @@ +// +// TunnelCommandPreviewTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +struct TunnelCommandPreviewTests { + private func coordinator(type: DatabaseType) -> ConnectionFormCoordinator { + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.network.type = type + coordinator.transport = .tunnelCommand + return coordinator + } + + @Test("A cleared Port previews the engine's default port, which is what connects") + func clearedPortPreviewsDefaultPort() throws { + let coordinator = coordinator(type: .postgresql) + coordinator.tunnelCommand.state.config.method = .kubectl + coordinator.tunnelCommand.state.config.kubernetesResource = "service/postgres" + coordinator.network.port = "" + + let preview = try #require(coordinator.tunnelCommandPreview) + + #expect(preview.contains("{port}:5432")) + #expect(!preview.contains(":0")) + } + + @Test("A host-list connection previews the first listed node, which is what connects") + func hostListPreviewsFirstListedNode() throws { + let coordinator = coordinator(type: .redis) + coordinator.network.host = "" + coordinator.network.additionalFieldValues["redisMode"] = "cluster" + coordinator.network.additionalFieldValues["redisClusterHosts"] = "a.example:7000,b.example:7001" + coordinator.tunnelCommand.state.config.method = .custom + coordinator.tunnelCommand.state.config.command = "forward {port} {host}:{remotePort}" + + let preview = try #require(coordinator.tunnelCommandPreview) + + #expect(preview == "forward {port} a.example:7000") + } +} diff --git a/TableProTests/Views/ConnectionForm/NetworkPaneTunnelHostListCaptionTests.swift b/TableProTests/Views/ConnectionForm/NetworkPaneTunnelHostListCaptionTests.swift new file mode 100644 index 0000000000..d3689c41a7 --- /dev/null +++ b/TableProTests/Views/ConnectionForm/NetworkPaneTunnelHostListCaptionTests.swift @@ -0,0 +1,87 @@ +// +// NetworkPaneTunnelHostListCaptionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct NetworkPaneTunnelHostListCaptionTests { + private func viewModel(type: DatabaseType, fields: [String: String]) -> NetworkPaneViewModel { + let viewModel = NetworkPaneViewModel() + viewModel.type = type + viewModel.additionalFieldValues = fields + return viewModel + } + + @Test("Redis Sentinel says Sentinel cannot run through a tunnel, not that failover is off") + func sentinelCaption() throws { + let caption = try #require(viewModel( + type: .redis, + fields: ["redisMode": "sentinel", "redisSentinelHosts": "s1.example:26379,s2.example:26379"] + ).tunnelHostListCaption) + + #expect(!caption.localizedCaseInsensitiveContains("replica set")) + #expect(caption.localizedCaseInsensitiveContains("Sentinel")) + } + + @Test("Redis Sentinel is refused through a tunnel with a single node too") + func sentinelCaptionWithOneNode() { + let caption = viewModel( + type: .redis, + fields: ["redisMode": "sentinel", "redisSentinelHosts": "s1.example:26379"] + ).tunnelHostListCaption + + #expect(caption != nil) + } + + @Test("Redis Cluster names the first node, not a replica set") + func clusterCaption() throws { + let caption = try #require(viewModel( + type: .redis, + fields: ["redisMode": "cluster", "redisClusterHosts": "n1.example:7000,n2.example:7001"] + ).tunnelHostListCaption) + + #expect(!caption.localizedCaseInsensitiveContains("replica set")) + #expect(caption.localizedCaseInsensitiveContains("node")) + } + + @Test("Redis Cluster warns with a single seed node, since the tunnel still reaches only that node") + func clusterCaptionWithOneSeed() { + let caption = viewModel( + type: .redis, + fields: ["redisMode": "cluster", "redisClusterHosts": "n1.example:7000"] + ).tunnelHostListCaption + + #expect(caption != nil) + } + + @Test("Kafka names the first broker, not a replica set") + func kafkaCaption() throws { + let caption = try #require(viewModel( + type: .kafka, + fields: ["kafkaBootstrapServers": "b1.example:9092,b2.example:9092"] + ).tunnelHostListCaption) + + #expect(!caption.localizedCaseInsensitiveContains("replica set")) + #expect(caption.localizedCaseInsensitiveContains("broker")) + } + + @Test("MongoDB keeps its replica set caption") + func mongoCaption() throws { + let caption = try #require(viewModel( + type: .mongodb, + fields: ["mongoHosts": "a.example:27017,b.example:27017"] + ).tunnelHostListCaption) + + #expect(caption.localizedCaseInsensitiveContains("replica set")) + } + + @Test("A single MongoDB host and an engine without a host list get no caption") + func noCaption() { + #expect(viewModel(type: .mongodb, fields: ["mongoHosts": "a.example:27017"]).tunnelHostListCaption == nil) + #expect(viewModel(type: .postgresql, fields: [:]).tunnelHostListCaption == nil) + } +}