From 8bc70c7db8065b04af4046fec95d5d6c109634b1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 00:11:44 +0700 Subject: [PATCH 1/2] fix(duckdb): trust a private CA, pin certificates and tunnel remote Quack connections Remote DuckDB never used the SSL settings the Network tab showed, and DuckDB checks certificates against /etc/ssl/cert.pem, not the macOS keychain, so a server with a private or self-signed certificate could not be reached. - Remote mode has its own SSL Mode: Automatic, Disabled, Required, Verify Identity with an optional CA file, and Verify Fingerprint. - Tunnels forward to the server fields a type declares, and a stored tunnel opens only where the driver dials it. - The Remote session ignores persistent DuckDB secrets, requires the Token, and drops its token secret right after ATTACH. - An older driver is refused a setting or a tunnel it cannot secure. - quack:// URLs carry sslmode and ssl_fingerprint; +ssh paths are encoded and IPv6 hosts bracketed. - Curated and plugin capabilities are checked for parity in Repo Hygiene, fixing the foreign key checkbox and container names it found. --- .github/workflows/repo-hygiene.yml | 4 + apple/Packages/TableProCore/Package.swift | 16 +- .../ConnectionFieldTrust.swift | 3 + .../HostListEndpoint.swift | 9 +- .../QuackCertificateFingerprint.swift | 24 + .../QuackConnectError.swift | 172 ++++++ .../TableProDuckDBCore/QuackConnectPlan.swift | 190 ++++++ .../QuackExtensionLoader.swift | 48 ++ .../TableProDuckDBCore/QuackSSLMode.swift | 20 + .../TableProDuckDBCore/QuackTrustBundle.swift | 36 ++ .../QuackCertificateFingerprintTests.swift | 47 ++ .../QuackConnectErrorClassifierTests.swift | 111 ++++ .../QuackConnectPlanTests.swift | 338 +++++++++++ .../QuackExtensionLoaderTests.swift | 91 +++ .../QuackTrustBundleTests.swift | 82 +++ .../BeancountPlugin.swift | 2 + .../ClickHousePlugin.swift | 1 + .../DuckDBDriverPlugin/DuckDBConnection.swift | 11 + .../DuckDBDriverPlugin/DuckDBPlugin.swift | 133 +++-- .../DuckDBTrustBundleFile.swift | 65 +++ .../QuackConnectBuilder.swift | 34 -- .../MSSQLDriverPlugin/MSSQLPlugin.swift | 3 + .../MemcachedPlugin.swift | 1 + .../SurrealDBPlugin.swift | 1 + .../TeradataDriverPlugin/TeradataPlugin.swift | 2 + .../Core/Database/DatabaseDriver.swift | 3 + .../Core/Database/DatabaseManager+SSH.swift | 3 +- .../Database/DatabaseManager+Tunnel.swift | 5 + .../DriverSettingNotSupportedError.swift | 82 +++ .../Network/LocalNetworkAccessDiagnosis.swift | 4 + .../ConnectionField+PasswordHiding.swift | 22 +- ...adataRegistry+DuckDBConnectionFields.swift | 29 + ...luginMetadataRegistry+DuckDBDefaults.swift | 11 +- ...ginMetadataRegistry+RegistryDefaults.swift | 4 +- .../Core/Plugins/PluginMetadataRegistry.swift | 21 + .../ExternalConnectionPrompting.swift | 3 +- .../Connection/ConnectionScriptContext.swift | 4 +- .../Connection/ConnectionURLFormatter.swift | 67 ++- .../Connection/ConnectionURLParser.swift | 47 +- .../Connection/DuckDBSSLModeField.swift | 73 +++ .../Connection/ConnectionTunnelKind.swift | 8 +- .../DatabaseConnection+Destination.swift | 26 +- .../DatabaseConnection+Display.swift | 8 + .../DatabaseConnection+LocalSocket.swift | 2 +- .../DatabaseConnection+Tunnel.swift | 52 +- .../Connection/DatabaseConnection.swift | 13 +- .../Connection/ServerEndpointFields.swift | 41 ++ .../TablePro/Resources/Localizable.xcstrings | 96 +++ .../ProjectFolderCandidateList.swift | 3 +- .../Components/ImportFromURLSheet.swift | 7 +- .../ConnectionFormCoordinator+Transport.swift | 12 +- .../ConnectionFormCoordinator.swift | 5 +- .../Transports/SSHTransportSections.swift | 4 +- .../Database/DatabaseManagerTunnelTests.swift | 98 ++++ .../DriverSettingNotSupportedErrorTests.swift | 121 ++++ .../Database/PromptedPasswordKeyTests.swift | 26 + .../LocalNetworkAccessDiagnosisTests.swift | 13 + .../ConnectionFieldTrustCoverageTests.swift | 1 + .../HealthMonitorOptOutParityTests.swift | 87 --- .../Core/Plugins/PasswordHidingTests.swift | 73 +++ ...tadataRegistryCuratedCapabilityTests.swift | 32 + .../Core/SSH/SSHForwardDestinationTests.swift | 15 + .../ConnectionScriptContextTests.swift | 13 + .../ConnectionURLFormatterTests.swift | 198 +++++++ .../Utilities/ConnectionURLParserTests.swift | 43 ++ .../Models/ConnectionTunnelKindTests.swift | 58 +- .../DatabaseConnectionDisplayTests.swift | 27 + .../Models/TunnelForwardEndpointTests.swift | 59 ++ .../ConnectionFormTransportTests.swift | 83 +++ .../ConnectionFormURLImportTests.swift | 13 + apple/project.yml | 2 +- apple/scripts/ci/check-capability-parity.py | 552 ++++++++++++++++++ .../ci/test_check_capability_parity.py | 413 +++++++++++++ apple/scripts/probes/check-quack-tls.sh | 269 +++++++++ 74 files changed, 4070 insertions(+), 225 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackCertificateFingerprint.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectError.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectPlan.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackExtensionLoader.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackSSLMode.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackTrustBundle.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackCertificateFingerprintTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectErrorClassifierTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectPlanTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackExtensionLoaderTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackTrustBundleTests.swift create mode 100644 apple/Plugins/DuckDBDriverPlugin/DuckDBTrustBundleFile.swift delete mode 100644 apple/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift create mode 100644 apple/TablePro/Core/Database/DriverSettingNotSupportedError.swift create mode 100644 apple/TablePro/Core/Utilities/Connection/DuckDBSSLModeField.swift create mode 100644 apple/TablePro/Models/Connection/ServerEndpointFields.swift create mode 100644 apple/TableProTests/Core/Database/DriverSettingNotSupportedErrorTests.swift delete mode 100644 apple/TableProTests/Core/Plugins/HealthMonitorOptOutParityTests.swift create mode 100644 apple/scripts/ci/check-capability-parity.py create mode 100644 apple/scripts/ci/test_check_capability_parity.py create mode 100755 apple/scripts/probes/check-quack-tls.sh diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index e01252bb9c..c5bf8414cc 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -91,6 +91,10 @@ jobs: - name: Check every plugin Info.plist declares the ids its class provides run: python3 apple/scripts/ci/check-plugin-provides.py + # The curated entry answers until the plugin loads, then the plugin's static replaces it (#2700). + - name: Check curated driver capabilities match their plugins + run: python3 apple/scripts/ci/check-capability-parity.py + # Import, export and sync drop a field the trust catalog does not classify. - name: Check every connection field has a trust class run: python3 apple/scripts/ci/check-connection-field-trust.py diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index 063de71e9b..c1ee902353 100644 --- a/apple/Packages/TableProCore/Package.swift +++ b/apple/Packages/TableProCore/Package.swift @@ -68,7 +68,8 @@ let package = Package( .library(name: "TableProWireIO", targets: ["TableProWireIO"]), .library(name: "TableProGzip", targets: ["TableProGzip"]), .library(name: "TableProKafkaCore", targets: ["TableProKafkaCore"]), - .library(name: "TableProCassandraCore", targets: ["TableProCassandraCore"]) + .library(name: "TableProCassandraCore", targets: ["TableProCassandraCore"]), + .library(name: "TableProDuckDBCore", targets: ["TableProDuckDBCore"]) ], // Kafka brokers hand back whatever codec the producer stored, and macOS has no zstd. dependencies: [ @@ -367,6 +368,13 @@ let package = Package( path: "Sources/TableProCassandraCore", swiftSettings: pluginCoreSettings ), + // Linked by a plugin bundle, so never TableProPluginKit. + .target( + name: "TableProDuckDBCore", + dependencies: ["TableProSQLQuoting"], + path: "Sources/TableProDuckDBCore", + swiftSettings: pluginCoreSettings + ), .testTarget( name: "TableProBigtableCoreTests", dependencies: ["TableProBigtableCore", "TableProGoogleCloud"], @@ -611,6 +619,12 @@ let package = Package( dependencies: ["TableProCassandraCore", "TableProSQLGrammar"], path: "Tests/TableProCassandraCoreTests", swiftSettings: pluginCoreSettings + ), + .testTarget( + name: "TableProDuckDBCoreTests", + dependencies: ["TableProDuckDBCore"], + path: "Tests/TableProDuckDBCoreTests", + swiftSettings: pluginCoreSettings ) ] ) diff --git a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/ConnectionFieldTrust.swift b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/ConnectionFieldTrust.swift index 682ff5a08b..b99565d3d9 100644 --- a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/ConnectionFieldTrust.swift +++ b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/ConnectionFieldTrust.swift @@ -134,9 +134,11 @@ public enum ConnectionFieldCatalog { "connectionOptions": .setting, "connectTimeoutSeconds": .setting, "duckdbAlias": .setting, + "duckdbCertificateFingerprint": .setting, "duckdbIdleReleaseMinutes": .setting, "duckdbMode": .setting, "duckdbReadOnly": .setting, + "duckdbSSLMode": .setting, "esAuthMethod": .setting, "esSkipTLSVerify": .setting, "etcdKeyPrefix": .setting, @@ -230,6 +232,7 @@ public enum ConnectionFieldCatalog { "sshForwardUnixSocketPath": .destination, "attachedDatabases": .localFile, + "duckdbCACertificateFilePath": .localFile, "duckdbFilePath": .localFile, "etcdCaCertPath": .localFile, "etcdClientCertPath": .localFile, diff --git a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/HostListEndpoint.swift b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/HostListEndpoint.swift index ab4b3b0300..360df979f1 100644 --- a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/HostListEndpoint.swift +++ b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/HostListEndpoint.swift @@ -15,7 +15,14 @@ public struct HostListEndpoint: Equatable, Sendable { } public var entry: String { - host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)" + "\(Self.urlHost(host)):\(port)" + } + + /// A host as a URL or a `host:port` pair writes it. An IPv6 address is all colons, so written bare its + /// last group reads back as the port and names another server. + public static func urlHost(_ host: String) -> String { + guard host.contains(":"), !host.hasPrefix("[") else { return host } + return "[\(host)]" } /// Takes `host`, `host:port`, `[address]:port`, a bare IPv6 address, or a pasted `http://` or diff --git a/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackCertificateFingerprint.swift b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackCertificateFingerprint.swift new file mode 100644 index 0000000000..b0558584fa --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackCertificateFingerprint.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum QuackCertificateFingerprint { + /// Quack's own reading of a pin (`QuackUri::NormalizeFingerprint`, quack_uri.cpp:14-38 at f96420ed): an + /// optional `sha256:` prefix in any case, then 64 hex digits in any case, with colons or spaces between + /// them as `openssl x509 -fingerprint` and Keychain Access print them. Read here so a typo is refused + /// before the connection opens, in words that name the field, rather than after the tunnel and the + /// extension download. The result is Quack's canonical form, 64 uppercase digits. + public static func normalized(_ input: String) throws -> String { + var text = Substring(input.trimmingCharacters(in: .whitespacesAndNewlines)) + guard !text.isEmpty else { throw QuackConnectError.missingFingerprint } + let prefix = "sha256:" + if text.prefix(prefix.count).lowercased() == prefix { + text = text.dropFirst(prefix.count) + } + var digits = String.UnicodeScalarView() + for scalar in text.unicodeScalars where scalar != ":" && scalar != " " { + guard scalar.properties.isASCIIHexDigit else { throw QuackConnectError.invalidFingerprint } + digits.append(scalar) + } + guard digits.count == 64 else { throw QuackConnectError.invalidFingerprint } + return String(digits).uppercased() + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectError.swift b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectError.swift new file mode 100644 index 0000000000..1af38ff4c8 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectError.swift @@ -0,0 +1,172 @@ +import Foundation + +public enum QuackConnectError: Error, LocalizedError, Equatable, Sendable { + case missingHost + case missingToken + case invalidPort + case missingFingerprint + case invalidFingerprint + case tunneledVerifyIdentity + case tunneledAutomatic(server: String) + case caCertificateUnreadable(path: String, reason: String) + case caCertificateNotPEM(path: String) + case trustBundleUnwritable(reason: String) + case extensionNotInstalled(name: String, detail: String) + case extensionNotLoaded(name: String, detail: String) + case certificateNotTrusted(detail: String) + case serverExpectsHTTPS(detail: String, tunneled: Bool) + case serverExpectsPlainHTTP(detail: String) + case fingerprintMismatch(detail: String) + case caCertificateRejected(detail: String) + + public var errorDescription: String? { + switch self { + case .missingHost: + return String(localized: "Host is required for a remote DuckDB connection") + case .missingToken: + return String(localized: "Token is required. Enter the token the server was started with, from quack_serve.") + case .invalidPort: + return String(localized: "Port must be a number between 1 and 65535") + case .missingFingerprint: + return String(localized: """ + Verify Fingerprint needs the server's Certificate Fingerprint. Run quack_server_list() on the \ + server to read it. + """) + case .invalidFingerprint: + return String(localized: """ + The Certificate Fingerprint is not a SHA-256 fingerprint. Enter the 64 hex digits that \ + quack_server_list() shows on the server, with or without colons. + """) + case .tunneledVerifyIdentity: + return String(localized: """ + Verify Identity does not work through a tunnel. DuckDB checks the certificate against \ + 127.0.0.1 there, not against the server's name. Set SSL Mode to Verify Fingerprint, or to \ + Required (skip verify). + """) + case .tunneledAutomatic(let server): + return String( + format: String(localized: """ + Automatic does not work through a tunnel to %@. DuckDB would use HTTPS and check the \ + certificate against 127.0.0.1, not against the server's name. Set SSL Mode to Verify \ + Fingerprint or Required (skip verify), or to Disabled if the server serves plain HTTP. + """), + server + ) + case .caCertificateUnreadable(let path, let reason): + return String(format: String(localized: "TablePro could not read the CA Certificate at %@: %@"), path, reason) + case .caCertificateNotPEM(let path): + return String( + format: String(localized: """ + The CA Certificate at %@ is not a PEM certificate. DuckDB reads PEM files only. Convert a DER \ + file with: openssl x509 -inform der -in ca.der -out ca.pem + """), + path + ) + case .trustBundleUnwritable(let reason): + return String(format: String(localized: "TablePro could not write the CA bundle DuckDB reads: %@"), reason) + case .extensionNotInstalled(let name, let detail): + return Self.reported( + String( + format: String(localized: """ + DuckDB could not install its %@ extension. It downloads the extension from \ + extensions.duckdb.org the first time it connects to a remote database. + """), + name + ), + detail: detail + ) + case .extensionNotLoaded(let name, let detail): + return Self.reported( + String(format: String(localized: "DuckDB could not load its %@ extension."), name), + detail: detail + ) + case .certificateNotTrusted(let detail): + return Self.reported( + String(localized: """ + DuckDB could not verify the server's certificate. Either no CA it trusts signed it, or it \ + was not issued for this host name. DuckDB checks certificates against /etc/ssl/cert.pem and \ + the CA Certificate, and does not read the macOS keychain, so a CA added in Keychain Access \ + is not trusted. + """), + String(localized: """ + Set SSL Mode to Verify Identity and choose the CA that signed the certificate in CA \ + Certificate, or set SSL Mode to Verify Fingerprint and enter the server's Certificate \ + Fingerprint. Required (skip verify) also connects, but does not check the certificate. + """), + detail: detail + ) + case .serverExpectsHTTPS(let detail, let tunneled): + return Self.reported( + String(localized: "The server uses HTTPS, and this connection sent plain HTTP."), + tunneled + ? String(localized: """ + Set SSL Mode to Verify Fingerprint, or to Required (skip verify) to encrypt without \ + checking the certificate. + """) + : String(localized: """ + Set SSL Mode to Verify Identity, or to Verify Fingerprint for a server with a \ + self-signed certificate. + """), + detail: detail + ) + case .serverExpectsPlainHTTP(let detail): + return Self.reported( + String(localized: "The server did not answer the TLS handshake. If it serves plain HTTP, set SSL Mode to Disabled."), + detail: detail + ) + case .fingerprintMismatch(let detail): + return Self.reported( + String(localized: """ + The server's certificate does not match the Certificate Fingerprint. If the server has a new \ + certificate, copy its fingerprint again from quack_server_list() on the server. + """), + detail: detail + ) + case .caCertificateRejected(let detail): + return Self.reported( + String(localized: "DuckDB could not read the CA Certificate. It reads PEM files only."), + detail: detail + ) + } + } + + /// DuckDB's own text stays at the end: it carries the URL that was dialed, which tells a tunnel's + /// loopback from the server. + private static func reported(_ parts: String..., detail: String) -> String { + (parts + [String(format: String(localized: "DuckDB reported: %@"), detail)]).joined(separator: "\n\n") + } +} + +/// Maps the texts quack and httpfs's curl give for a TLS mismatch to messages that name the field to +/// change. The texts were measured on DuckDB v2.0.0 with quack f96420ed and httpfs 77392a7; any other +/// failure keeps DuckDB's own text. +public enum QuackConnectErrorClassifier { + public static func classify(_ error: any Error, tunneled: Bool) -> QuackConnectError? { + if let error = error as? QuackConnectError { return error } + return classify(message: error.localizedDescription, tunneled: tunneled) + } + + public static func classify(message: String, tunneled: Bool) -> QuackConnectError? { + // The pinned client's own text, from a server whose certificate is not the pinned one. + if message.contains("does not match the pinned ssl_fingerprint") { + return .fingerprintMismatch(detail: message) + } + // curl gives one text for a CA it does not trust and for a certificate issued to another name, + // so one message covers both. + if message.contains("SSL peer certificate or SSH remote key was not OK") { + return .certificateNotTrusted(detail: message) + } + if message.contains("Problem with the SSL CA cert") { + return .caCertificateRejected(detail: message) + } + // Plain HTTP to a server that speaks TLS. + if message.contains("Unsupported protocol") { + return .serverExpectsHTTPS(detail: message, tunneled: tunneled) + } + // HTTPS to a plain HTTP server: curl's text, then the pinned client's. + if message.contains("SSL connect error") || message.contains("SSL connection failed") { + return .serverExpectsPlainHTTP(detail: message) + } + return nil + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectPlan.swift b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectPlan.swift new file mode 100644 index 0000000000..38fea57b87 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackConnectPlan.swift @@ -0,0 +1,190 @@ +import Foundation +import TableProSQLQuoting + +/// What a remote DuckDB connection's fields say, as stored. +public struct QuackConnectRequest: Sendable, Equatable { + public var host: String + public var port: String + public var alias: String + public var token: String + public var sslMode: String? + public var caCertificatePath: String? + public var certificateFingerprint: String? + /// The server's own host when `host` and `port` are a tunnel's loopback forward, else nil. + public var tunneledServerHost: String? + + public init( + host: String, + port: String = "", + alias: String = "", + token: String = "", + sslMode: String? = nil, + caCertificatePath: String? = nil, + certificateFingerprint: String? = nil, + tunneledServerHost: String? = nil + ) { + self.host = host + self.port = port + self.alias = alias + self.token = token + self.sslMode = sslMode + self.caCertificatePath = caCertificatePath + self.certificateFingerprint = certificateFingerprint + self.tunneledServerHost = tunneledServerHost + } +} + +/// The statements that attach a Quack server, in the order they run, or the reason they cannot. +/// +/// Every refusal is thrown from `init`, so it reaches the user before the in-memory instance opens, the +/// extensions download or anything dials the server. +public struct QuackConnectPlan: Sendable, Equatable { + public enum Step: Sendable, Equatable { + /// Runs through `QuackExtensionLoader.load`. + case loadExtension(String) + /// The user's CA file. The caller builds the bundle with `QuackTrustBundle.combined`, writes it, + /// and runs `setCACertFileStatement(bundlePath:)` with the path it wrote. + case trustCACertificate(path: String) + case statement(String) + } + + public static let defaultPort = 9_494 + public static let defaultAlias = "remotedb" + public static let secretName = "tablepro_quack" + + public let alias: String + public let mode: QuackSSLMode + public let isTunneled: Bool + public let steps: [Step] + + public init(_ request: QuackConnectRequest) throws { + let host = request.host.trimmingCharacters(in: .whitespaces) + guard !host.isEmpty else { throw QuackConnectError.missingHost } + // Every Quack server has a token, and the client refuses to connect without one. + guard !request.token.isEmpty else { throw QuackConnectError.missingToken } + guard let port = Self.port(from: request.port) else { throw QuackConnectError.invalidPort } + let aliasInput = request.alias.trimmingCharacters(in: .whitespaces) + let alias = aliasInput.isEmpty ? Self.defaultAlias : aliasInput + let mode = QuackSSLMode(fieldValue: request.sslMode) + + // PluginKit's test for a forward reads the built-in host, which DuckDB leaves at localhost, so it + // passes on a direct dial too. A forward is always dialed on the loopback, so a server name that + // comes with any other dial is ignored. + let serverHost = request.tunneledServerHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let tunneledServer: String? = (!serverHost.isEmpty && QuackHost.isLocal(host)) ? serverHost : nil + + var steps: [Step] = [.loadExtension("quack")] + var options: [String] = [] + if !request.token.isEmpty { + options.append("SECRET \(Self.secretName)") + } + + switch mode { + case .automatic: + if let tunneledServer { + // Quack picks plain HTTP for a local host and HTTPS checked against the system bundle and + // the host name for any other. The loopback is always local to it, so its rule is applied + // here to the server's own name. HTTPS that way would check the certificate against + // 127.0.0.1 and fail, so a non-local server needs a mode that does not check the name. + guard QuackHost.isLocal(tunneledServer) else { + throw QuackConnectError.tunneledAutomatic(server: tunneledServer) + } + options.append("DISABLE_SSL true") + } + // A direct dial names no option, so Quack's own rule applies, which is what every connection + // saved before SSL Mode existed did. + case .disabled: + options.append("DISABLE_SSL true") + case .required: + // httpfs's settings exist only once it is loaded. Elsewhere the ATTACH loads httpfs itself + // when its transport needs it. GLOBAL for the reason `setCACertFileStatement` gives; httpfs has + // no per-host switch, so the editor's own HTTPS reads on this connection go unchecked too. + steps.append(.loadExtension("httpfs")) + steps.append(.statement("SET GLOBAL enable_curl_server_cert_verification = false")) + options.append("DISABLE_SSL false") + case .verifyIdentity: + // curl checks the name the URL dials, which through a forward is 127.0.0.1, never the server's. + guard tunneledServer == nil else { throw QuackConnectError.tunneledVerifyIdentity } + let caPath = request.caCertificatePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !caPath.isEmpty { + steps.append(.loadExtension("httpfs")) + steps.append(.trustCACertificate(path: caPath)) + } + options.append("DISABLE_SSL false") + case .verifyFingerprint: + // The pinned client is Quack's own and never loads httpfs, so no httpfs step: a pin keeps + // working on a Mac that cannot download it. + let fingerprint = try QuackCertificateFingerprint.normalized(request.certificateFingerprint ?? "") + options.append("DISABLE_SSL false") + options.append("SSL_FINGERPRINT \(SQLQuoting.stringLiteral(fingerprint))") + } + + steps.append(.statement(Self.secretStatement(token: request.token))) + steps.append(.statement(Self.attachStatement(host: host, port: port, alias: alias, options: options))) + steps.append(.statement(Self.dropSecretStatement)) + steps.append(.statement(Self.useStatement(alias: alias))) + + self.alias = alias + self.mode = mode + self.isTunneled = tunneledServer != nil + self.steps = steps + } + + /// GLOBAL because each connect opens its own in-memory instance, so it reaches no other connection, + /// and a heartbeat that opens a new pooled client reads global values, not this session's. + public static func setCACertFileStatement(bundlePath: String) -> String { + "SET GLOBAL ca_cert_file = \(SQLQuoting.stringLiteral(bundlePath))" + } + + /// The token goes in a secret rather than an ATTACH `TOKEN` option, which `duckdb_databases()` shows in + /// plain text to every statement the session runs. The session opens with persistent secrets off, so + /// this is the only secret ATTACH can find, for the token and for a fingerprint alike. + static func secretStatement(token: String) -> String { + "CREATE OR REPLACE TEMPORARY SECRET \(secretName) (TYPE quack, TOKEN \(SQLQuoting.stringLiteral(token)))" + } + + /// ATTACH keeps the token in the catalog it creates, so the secret is dropped as soon as ATTACH + /// returns. Left in place, a later `ATTACH` in the editor or from an AI client found it by scope and + /// sent this server's token to the host it named, and a scope does not stop that: DuckDB matches a + /// scope as a prefix, so `quack:localhost:5000` also reaches `quack:localhost:50000` (both measured). + static let dropSecretStatement = "DROP TEMPORARY SECRET \(secretName)" + + static func attachStatement(host: String, port: Int, alias: String, options: [String]) -> String { + let uri = "quack:\(QuackHost.uriHost(host)):\(port)" + let attach = "ATTACH \(SQLQuoting.stringLiteral(uri)) AS \(SQLQuoting.identifier(alias))" + guard !options.isEmpty else { return attach } + return "\(attach) (\(options.joined(separator: ", ")))" + } + + static func useStatement(alias: String) -> String { + "USE \(SQLQuoting.identifier(alias))" + } + + static func port(from raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return defaultPort } + guard let port = Int(trimmed), (1...65_535).contains(port) else { return nil } + return port + } +} + +enum QuackHost { + /// Quack's own set of local hosts, which it dials over plain HTTP by default + /// (`QuackUri::IsLocal`, quack_uri.hpp:51-53 at f96420ed). + static func isLocal(_ host: String) -> Bool { + let bare = unbracketed(host) + return bare.lowercased() == "localhost" || bare == "127.0.0.1" || bare == "::1" + } + + /// Quack reads the first colon after the host as the start of the port, so an IPv6 literal typed + /// without brackets failed with "Invalid Port". + static func uriHost(_ host: String) -> String { + guard !host.hasPrefix("["), host.filter({ $0 == ":" }).count >= 2 else { return host } + return "[\(host)]" + } + + private static func unbracketed(_ host: String) -> String { + guard host.hasPrefix("["), host.hasSuffix("]"), host.count >= 2 else { return host } + return String(host.dropFirst().dropLast()) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackExtensionLoader.swift b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackExtensionLoader.swift new file mode 100644 index 0000000000..19e315feb4 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackExtensionLoader.swift @@ -0,0 +1,48 @@ +import Foundation + +public enum QuackExtensionLoader { + /// LOAD first, because an installed extension loads with no network. Only when that fails does INSTALL + /// run, then LOAD again. When both fail the INSTALL error is the one reported: the second LOAD says + /// 'Install it first using "INSTALL quack"', a command that has just run, while INSTALL names the + /// cause, such as "Failed to download extension" with no route to the extension repository (measured + /// on DuckDB v2.0.0). + /// + /// `execute` runs one statement. A cancelled connect is rethrown as it is; an attempt that ended any + /// other way is the caller's to tell apart, from its own state. + public static func load(_ name: String, execute: (String) async throws -> Void) async throws { + do { + try await execute(loadStatement(name)) + return + } catch is CancellationError { + throw CancellationError() + } catch {} + + var installFailure: (any Error)? + do { + try await execute(installStatement(name)) + } catch is CancellationError { + throw CancellationError() + } catch { + installFailure = error + } + + do { + try await execute(loadStatement(name)) + } catch is CancellationError { + throw CancellationError() + } catch { + if let installFailure { + throw QuackConnectError.extensionNotInstalled(name: name, detail: installFailure.localizedDescription) + } + throw QuackConnectError.extensionNotLoaded(name: name, detail: error.localizedDescription) + } + } + + public static func loadStatement(_ name: String) -> String { + "LOAD \(name)" + } + + public static func installStatement(_ name: String) -> String { + "INSTALL \(name)" + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackSSLMode.swift b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackSSLMode.swift new file mode 100644 index 0000000000..887ce2a4f3 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackSSLMode.swift @@ -0,0 +1,20 @@ +import Foundation + +/// The SSL Mode a remote DuckDB connection stores in its own field. +/// +/// Not the shared SSL Mode: that one is keyed to the built-in Host, which DuckDB never dials, and every +/// connection saved before this field existed stores Disabled while Quack reaches any host but a local +/// one over HTTPS. These are the outcomes quack and httpfs can give a client, and nothing more: there is +/// no client certificate and no CA check without the host name check. +public enum QuackSSLMode: String, CaseIterable, Sendable { + case automatic + case disabled + case required + case verifyIdentity + case verifyFingerprint + + /// A connection saved before the field existed has no value, and Automatic is what it did then. + public init(fieldValue: String?) { + self = fieldValue.flatMap { Self(rawValue: $0) } ?? .automatic + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackTrustBundle.swift b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackTrustBundle.swift new file mode 100644 index 0000000000..044b92d1b8 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProDuckDBCore/QuackTrustBundle.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum QuackTrustBundle { + /// Where httpfs looks for its default bundle when `ca_cert_file` is empty, in its order + /// (`CERT_FILE_LOCATIONS`, httpfs_certificates.cpp:13-23 at 77392a7). The first that exists wins. On + /// macOS that is the last one, which never holds a CA added to the keychain. + public static let defaultBundleLocations = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/ssl/cert.pem" + ] + + private static let pemCertificateMarker = Data("-----BEGIN CERTIFICATE-----".utf8) + + /// The bundle `ca_cert_file` points at for Verify Identity with a CA. + /// + /// `SET GLOBAL ca_cert_file` replaces httpfs's default bundle for every HTTPS read of the DuckDB + /// instance, not only Quack's, so a bundle of the user's CA alone would stop `read_parquet('https://…')` + /// in the editor from trusting any public server. The default bundle goes first, then the CA. + /// + /// httpfs reads the file as PEM only and reports a DER file as "Problem with the SSL CA cert", which + /// names neither the field nor the fix, so the CA is checked here instead. + public static func combined(defaultBundle: Data?, caCertificate: Data, caCertificatePath: String) throws -> Data { + guard caCertificate.range(of: pemCertificateMarker) != nil else { + throw QuackConnectError.caCertificateNotPEM(path: caCertificatePath) + } + var bundle = defaultBundle ?? Data() + if let last = bundle.last, last != UInt8(ascii: "\n") { + bundle.append(UInt8(ascii: "\n")) + } + bundle.append(caCertificate) + return bundle + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackCertificateFingerprintTests.swift b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackCertificateFingerprintTests.swift new file mode 100644 index 0000000000..f938cb7b6e --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackCertificateFingerprintTests.swift @@ -0,0 +1,47 @@ +import Foundation +import TableProDuckDBCore +import Testing + +private let canonical = "34FD9DAA82DDA466FF659D56FA871BADF5324F4FA5BB6A09CD06ED349BB4AD58" +private let colons = "34:FD:9D:AA:82:DD:A4:66:FF:65:9D:56:FA:87:1B:AD:F5:32:4F:4F:A5:BB:6A:09:CD:06:ED:34:9B:B4:AD:58" + +struct QuackCertificateFingerprintTests { + @Test("Every form Quack accepts reads as its canonical 64 uppercase digits", arguments: [ + colons, + colons.lowercased(), + canonical, + canonical.lowercased(), + colons.replacingOccurrences(of: ":", with: " "), + "sha256:" + colons, + "SHA256:" + canonical, + "Sha256: " + colons, + " \(colons)\n", + ]) + func acceptsQuackForms(input: String) throws { + #expect(try QuackCertificateFingerprint.normalized(input) == canonical) + } + + @Test("Anything Quack refuses is refused before dialing", arguments: [ + "SHA256 Fingerprint=" + colons, + "SHA-256:" + colons, + "EB:1C:23", + "zz", + String(canonical.dropLast()), + canonical + "A", + colons.replacingOccurrences(of: ":", with: "-"), + colons.replacingOccurrences(of: ":", with: "\t"), + "\u{FF13}" + String(canonical.dropFirst()), + ]) + func refusesOtherForms(input: String) { + #expect(throws: QuackConnectError.invalidFingerprint) { + try QuackCertificateFingerprint.normalized(input) + } + } + + @Test("A blank fingerprint asks for one", arguments: ["", " ", "\n"]) + func blankIsMissing(input: String) { + #expect(throws: QuackConnectError.missingFingerprint) { + try QuackCertificateFingerprint.normalized(input) + } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectErrorClassifierTests.swift b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectErrorClassifierTests.swift new file mode 100644 index 0000000000..1e9eb1d66a --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectErrorClassifierTests.swift @@ -0,0 +1,111 @@ +import Foundation +import TableProDuckDBCore +import Testing + +// Measured on DuckDB v2.0.0 with quack f96420ed and httpfs 77392a7, verbatim. +private let untrusted = "IO Error: Failed to send message: IO Error: SSL peer certificate or SSH remote key was not OK " + + "error for HTTP POST to 'https://192.168.170.165:9494/quack'" +private let plainToTLS = "IO Error: Failed to send message: IO Error: Unsupported protocol error for HTTP POST to " + + "'http://127.0.0.1:9494/quack'" +private let tlsToPlain = "IO Error: Failed to send message: IO Error: SSL connect error error for HTTP POST to " + + "'https://172.25.144.180:9494/quack'" +private let pinnedToPlain = "IO Error: Failed to send message: SSL connection failed error for HTTP POST to " + + "'https://172.25.144.180:9494/quack'" +private let pinMismatch = "IO Error: Failed to send message: the certificate presented by https://172.25.144.180:9494 " + + "does not match the pinned ssl_fingerprint" +private let derCA = "IO Error: Failed to send message: IO Error: Problem with the SSL CA cert (path? access rights?) " + + "error for HTTP POST to 'https://172.25.144.180:9494/quack'" + +private struct DuckDBText: Error, LocalizedError { + let text: String + var errorDescription: String? { text } +} + +struct QuackConnectErrorClassifierTests { + @Test("An untrusted or misnamed certificate names the three fields and says the keychain is not read") + func untrustedCertificate() throws { + let error = try #require(QuackConnectErrorClassifier.classify(message: untrusted, tunneled: false)) + #expect(error == .certificateNotTrusted(detail: untrusted)) + let message = try #require(error.errorDescription) + #expect(message.contains("SSL Mode")) + #expect(message.contains("CA Certificate")) + #expect(message.contains("Certificate Fingerprint")) + #expect(message.contains("keychain")) + #expect(message.contains(untrusted)) + } + + @Test("Plain HTTP to a TLS server asks for a TLS mode, never Verify Identity through a tunnel") + func plainHTTPToTLSServer() throws { + let direct = try #require(QuackConnectErrorClassifier.classify(message: plainToTLS, tunneled: false)) + #expect(direct == .serverExpectsHTTPS(detail: plainToTLS, tunneled: false)) + let directMessage = try #require(direct.errorDescription) + #expect(directMessage.contains("SSL Mode")) + #expect(directMessage.contains("Verify Identity")) + #expect(directMessage.contains("Verify Fingerprint")) + + let tunneled = try #require(QuackConnectErrorClassifier.classify(message: plainToTLS, tunneled: true)) + #expect(tunneled == .serverExpectsHTTPS(detail: plainToTLS, tunneled: true)) + let tunneledMessage = try #require(tunneled.errorDescription) + #expect(!tunneledMessage.contains("Verify Identity")) + #expect(tunneledMessage.contains("Verify Fingerprint")) + #expect(tunneledMessage.contains("Required (skip verify)")) + } + + @Test("HTTPS to a plain HTTP server, from curl or the pinned client, points at Disabled", + arguments: [tlsToPlain, pinnedToPlain]) + func tlsToPlainServer(text: String) throws { + let error = try #require(QuackConnectErrorClassifier.classify(message: text, tunneled: false)) + #expect(error == .serverExpectsPlainHTTP(detail: text)) + let message = try #require(error.errorDescription) + #expect(message.contains("SSL Mode")) + #expect(message.contains("Disabled")) + } + + @Test("A pin the server does not match names Certificate Fingerprint") + func pinMismatchNamesTheField() throws { + let error = try #require(QuackConnectErrorClassifier.classify(message: pinMismatch, tunneled: true)) + #expect(error == .fingerprintMismatch(detail: pinMismatch)) + #expect(try #require(error.errorDescription).contains("Certificate Fingerprint")) + } + + @Test("A CA file curl cannot read names CA Certificate and PEM") + func unreadableCA() throws { + let error = try #require(QuackConnectErrorClassifier.classify(message: derCA, tunneled: false)) + #expect(error == .caCertificateRejected(detail: derCA)) + let message = try #require(error.errorDescription) + #expect(message.contains("CA Certificate")) + #expect(message.contains("PEM")) + } + + @Test("Any other failure keeps DuckDB's own text", arguments: [ + "Invalid Input Error: Authentication failed", + "Invalid Input Error: Quack secret \"pinonly\" does not contain a token", + "IO Error: Failed to send message: IO Error: Cannot open file \"/nonexistent/ca.pem\": No such file or directory", + "Catalog Error: SET schema: No catalog + schema named \"remotedb\" found", + ]) + func otherFailuresPassThrough(text: String) { + #expect(QuackConnectErrorClassifier.classify(message: text, tunneled: false) == nil) + #expect(QuackConnectErrorClassifier.classify(DuckDBText(text: text), tunneled: false) == nil) + } + + @Test("A driver error is read through its description, and a refusal passes through as it is") + func classifiesErrors() { + #expect(QuackConnectErrorClassifier.classify(DuckDBText(text: untrusted), tunneled: false) + == .certificateNotTrusted(detail: untrusted)) + #expect(QuackConnectErrorClassifier.classify(QuackConnectError.tunneledVerifyIdentity, tunneled: true) + == .tunneledVerifyIdentity) + } + + @Test("Each refusal before dialing names the setting to change") + func refusalMessages() throws { + let automatic = try #require(QuackConnectError.tunneledAutomatic(server: "db.example.com").errorDescription) + #expect(automatic.contains("db.example.com")) + #expect(automatic.contains("SSL Mode")) + #expect(automatic.contains("Verify Fingerprint")) + let identity = try #require(QuackConnectError.tunneledVerifyIdentity.errorDescription) + #expect(identity.contains("Verify Identity")) + #expect(identity.contains("Verify Fingerprint")) + #expect(try #require(QuackConnectError.missingFingerprint.errorDescription).contains("Certificate Fingerprint")) + #expect(try #require(QuackConnectError.invalidFingerprint.errorDescription).contains("Certificate Fingerprint")) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectPlanTests.swift b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectPlanTests.swift new file mode 100644 index 0000000000..3e97873a78 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackConnectPlanTests.swift @@ -0,0 +1,338 @@ +import Foundation +import TableProDuckDBCore +import Testing + +private let pin = "34:FD:9D:AA:82:DD:A4:66:FF:65:9D:56:FA:87:1B:AD:F5:32:4F:4F:A5:BB:6A:09:CD:06:ED:34:9B:B4:AD:58" +private let canonicalPin = "34FD9DAA82DDA466FF659D56FA871BADF5324F4FA5BB6A09CD06ED349BB4AD58" +private let secret = "CREATE OR REPLACE TEMPORARY SECRET tablepro_quack (TYPE quack, TOKEN 'tok')" +private let dropSecret = "DROP TEMPORARY SECRET tablepro_quack" + +private func direct(_ mode: QuackSSLMode?, token: String = "tok", ca: String? = nil, fingerprint: String? = nil) throws -> QuackConnectPlan { + try QuackConnectPlan(QuackConnectRequest( + host: "192.168.1.3", + port: "9811", + alias: "remotedb", + token: token, + sslMode: mode?.rawValue, + caCertificatePath: ca, + certificateFingerprint: fingerprint + )) +} + +private func tunneled( + _ mode: QuackSSLMode?, + server: String = "db.example.com", + token: String = "tok", + ca: String? = nil, + fingerprint: String? = nil +) throws -> QuackConnectPlan { + try QuackConnectPlan(QuackConnectRequest( + host: "127.0.0.1", + port: "50123", + alias: "remotedb", + token: token, + sslMode: mode?.rawValue, + caCertificatePath: ca, + certificateFingerprint: fingerprint, + tunneledServerHost: server + )) +} + +private func statements(_ plan: QuackConnectPlan) -> [String] { + plan.steps.compactMap { + guard case .statement(let sql) = $0 else { return nil } + return sql + } +} + +private func attach(_ plan: QuackConnectPlan) -> String? { + statements(plan).first { $0.hasPrefix("ATTACH ") } +} + +struct QuackConnectPlanTests { + // MARK: Automatic + + @Test("Automatic on a direct dial names no DISABLE_SSL, so Quack's own host rule decides as it always has") + func automaticDirect() throws { + let plan = try direct(.automatic) + #expect(plan.steps == [ + .loadExtension("quack"), + .statement(secret), + .statement("ATTACH 'quack:192.168.1.3:9811' AS \"remotedb\" (SECRET tablepro_quack)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + #expect(plan.mode == .automatic) + #expect(!plan.isTunneled) + } + + @Test("A missing, empty or unknown mode is Automatic", arguments: [nil, "", "bogus", "Automatic", "verify-full"]) + func unknownModeIsAutomatic(value: String?) throws { + let plan = try QuackConnectPlan(QuackConnectRequest(host: "192.168.1.3", token: "tok", sslMode: value)) + #expect(plan.mode == .automatic) + #expect(attach(plan) == "ATTACH 'quack:192.168.1.3:9494' AS \"remotedb\" (SECRET tablepro_quack)") + } + + @Test("Automatic through a tunnel to a local server is plain HTTP, Quack's rule for that server's name", + arguments: ["localhost", "LOCALHOST", "127.0.0.1", "::1", "[::1]"]) + func automaticTunneledLocal(server: String) throws { + let plan = try tunneled(.automatic, server: server) + #expect(plan.isTunneled) + #expect(plan.steps == [ + .loadExtension("quack"), + .statement(secret), + .statement("ATTACH 'quack:127.0.0.1:50123' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL true)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + } + + @Test("Automatic through a tunnel to a non-local server is refused, naming the server", + arguments: ["db.example.com", "192.168.1.3", "127.0.0.2", "0.0.0.0"]) + func automaticTunneledRemoteIsRefused(server: String) { + #expect(throws: QuackConnectError.tunneledAutomatic(server: server)) { + try tunneled(.automatic, server: server) + } + } + + // MARK: Disabled + + @Test("Disabled is plain HTTP, direct and tunneled, with no httpfs step") + func disabled() throws { + #expect(try direct(.disabled).steps == [ + .loadExtension("quack"), + .statement(secret), + .statement("ATTACH 'quack:192.168.1.3:9811' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL true)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + let tunnel = try tunneled(.disabled) + #expect(tunnel.isTunneled) + #expect(attach(tunnel) == "ATTACH 'quack:127.0.0.1:50123' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL true)") + #expect(!tunnel.steps.contains(.loadExtension("httpfs"))) + } + + // MARK: Required + + @Test("Required loads httpfs, turns curl's check off before ATTACH and forces HTTPS") + func requiredDirect() throws { + #expect(try direct(.required).steps == [ + .loadExtension("quack"), + .loadExtension("httpfs"), + .statement("SET GLOBAL enable_curl_server_cert_verification = false"), + .statement(secret), + .statement("ATTACH 'quack:192.168.1.3:9811' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL false)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + } + + @Test("Required works through a tunnel: nothing checks the name there") + func requiredTunneled() throws { + let plan = try tunneled(.required) + #expect(plan.isTunneled) + #expect(plan.steps == [ + .loadExtension("quack"), + .loadExtension("httpfs"), + .statement("SET GLOBAL enable_curl_server_cert_verification = false"), + .statement(secret), + .statement("ATTACH 'quack:127.0.0.1:50123' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL false)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + } + + // MARK: Verify Identity + + @Test("Verify Identity with no CA forces HTTPS against httpfs's default bundle and sets nothing") + func verifyIdentityWithoutCA() throws { + for ca in [nil, "", " "] { + #expect(try direct(.verifyIdentity, ca: ca).steps == [ + .loadExtension("quack"), + .statement(secret), + .statement("ATTACH 'quack:192.168.1.3:9811' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL false)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + } + } + + @Test("Verify Identity with a CA loads httpfs and trusts the combined bundle before the secret and ATTACH") + func verifyIdentityWithCA() throws { + #expect(try direct(.verifyIdentity, ca: " /Users/me/rootCA.pem ").steps == [ + .loadExtension("quack"), + .loadExtension("httpfs"), + .trustCACertificate(path: "/Users/me/rootCA.pem"), + .statement(secret), + .statement("ATTACH 'quack:192.168.1.3:9811' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL false)"), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + } + + @Test("Verify Identity through a tunnel is refused before dialing, with or without a CA") + func verifyIdentityTunneledIsRefused() { + for ca in [nil, "/Users/me/rootCA.pem"] { + #expect(throws: QuackConnectError.tunneledVerifyIdentity) { + try tunneled(.verifyIdentity, ca: ca) + } + #expect(throws: QuackConnectError.tunneledVerifyIdentity) { + try tunneled(.verifyIdentity, server: "localhost", ca: ca) + } + } + } + + @Test("The CA path is quoted as a literal in SET GLOBAL ca_cert_file") + func caCertFileQuoting() { + #expect(QuackConnectPlan.setCACertFileStatement(bundlePath: "/tmp/it's/ca.pem") + == "SET GLOBAL ca_cert_file = '/tmp/it''s/ca.pem'") + #expect(QuackConnectPlan.setCACertFileStatement(bundlePath: "/tmp/x'; SET GLOBAL a = 'b") + == "SET GLOBAL ca_cert_file = '/tmp/x''; SET GLOBAL a = ''b'") + } + + // MARK: Verify Fingerprint + + @Test("Verify Fingerprint pins the normalized fingerprint on ATTACH and never loads httpfs") + func verifyFingerprintDirect() throws { + #expect(try direct(.verifyFingerprint, fingerprint: "sha256:" + pin.lowercased()).steps == [ + .loadExtension("quack"), + .statement(secret), + .statement( + "ATTACH 'quack:192.168.1.3:9811' AS \"remotedb\" " + + "(SECRET tablepro_quack, DISABLE_SSL false, SSL_FINGERPRINT '\(canonicalPin)')" + ), + .statement(dropSecret), + .statement("USE \"remotedb\"") + ]) + } + + @Test("Verify Fingerprint works through a tunnel to any server") + func verifyFingerprintTunneled() throws { + let plan = try tunneled(.verifyFingerprint, fingerprint: pin) + #expect(plan.isTunneled) + #expect(attach(plan) == "ATTACH 'quack:127.0.0.1:50123' AS \"remotedb\" " + + "(SECRET tablepro_quack, DISABLE_SSL false, SSL_FINGERPRINT '\(canonicalPin)')") + #expect(!plan.steps.contains(.loadExtension("httpfs"))) + } + + @Test("Verify Fingerprint with no fingerprint, or a malformed one, is refused before dialing") + func verifyFingerprintRefusals() { + #expect(throws: QuackConnectError.missingFingerprint) { try direct(.verifyFingerprint) } + #expect(throws: QuackConnectError.missingFingerprint) { try direct(.verifyFingerprint, fingerprint: " ") } + #expect(throws: QuackConnectError.invalidFingerprint) { try direct(.verifyFingerprint, fingerprint: "EB:1C:23") } + #expect(throws: QuackConnectError.invalidFingerprint) { try tunneled(.verifyFingerprint, fingerprint: "zz") } + } + + @Test("A fingerprint stored under another mode is ignored") + func fingerprintIgnoredOutsideItsMode() throws { + let plan = try direct(.required, fingerprint: "not a fingerprint") + #expect(attach(plan)?.contains("SSL_FINGERPRINT") == false) + } + + // MARK: Secret + + @Test("The token goes in a named temporary secret that ATTACH names and that is dropped right after it") + func secretLivesOnlyThroughAttach() throws { + for mode in QuackSSLMode.allCases { + let plan = try direct(mode, token: "s3cr3t-token", fingerprint: pin) + let sql = statements(plan) + let secretStatement = try #require(sql.first { $0.hasPrefix("CREATE ") }) + #expect(secretStatement == "CREATE OR REPLACE TEMPORARY SECRET tablepro_quack (TYPE quack, TOKEN 's3cr3t-token')") + let attachStatement = try #require(attach(plan)) + #expect(attachStatement.contains("SECRET tablepro_quack")) + #expect(!attachStatement.contains("s3cr3t-token")) + #expect(!attachStatement.contains("TOKEN")) + let secretIndex = try #require(sql.firstIndex(of: secretStatement)) + let attachIndex = try #require(sql.firstIndex(of: attachStatement)) + let dropIndex = try #require(sql.firstIndex(of: dropSecret)) + #expect(secretIndex < attachIndex) + #expect(dropIndex == attachIndex + 1) + } + } + + @Test("An empty token is refused before dialing, for every mode: Quack has no tokenless connection") + func emptyTokenIsRefused() { + for mode in QuackSSLMode.allCases { + #expect(throws: QuackConnectError.missingToken) { try direct(mode, token: "", fingerprint: pin) } + } + } + + // MARK: Tunnel detection + + @Test("A server name with a dial that is not the loopback is a direct dial") + func serverNameWithoutLoopbackIsDirect() throws { + let plan = try QuackConnectPlan(QuackConnectRequest( + host: "10.0.0.5", + token: "tok", + sslMode: QuackSSLMode.verifyIdentity.rawValue, + tunneledServerHost: "db.example.com" + )) + #expect(!plan.isTunneled) + #expect(attach(plan) == "ATTACH 'quack:10.0.0.5:9494' AS \"remotedb\" (SECRET tablepro_quack, DISABLE_SSL false)") + } + + @Test("A blank server name is a direct dial", arguments: [nil, "", " "]) + func blankServerNameIsDirect(server: String?) throws { + let plan = try QuackConnectPlan(QuackConnectRequest( + host: "localhost", + token: "tok", + sslMode: QuackSSLMode.verifyIdentity.rawValue, + tunneledServerHost: server + )) + #expect(!plan.isTunneled) + } + + // MARK: Host, port and alias + + @Test("A blank host is refused", arguments: ["", " "]) + func blankHostIsRefused(host: String) { + #expect(throws: QuackConnectError.missingHost) { try QuackConnectPlan(QuackConnectRequest(host: host, token: "tok")) } + } + + @Test("An empty port is Quack's default, and one outside 1 to 65535 is refused") + func ports() throws { + #expect(try attach(QuackConnectPlan(QuackConnectRequest(host: "h", token: "tok"))) == "ATTACH 'quack:h:9494' AS \"remotedb\" (SECRET tablepro_quack)") + #expect(try attach(QuackConnectPlan(QuackConnectRequest(host: "h", port: " 9500 ", token: "tok"))) == "ATTACH 'quack:h:9500' AS \"remotedb\" (SECRET tablepro_quack)") + for port in ["0", "65536", "-1", "abc", "94 94"] { + #expect(throws: QuackConnectError.invalidPort) { try QuackConnectPlan(QuackConnectRequest(host: "h", port: port, token: "tok")) } + } + } + + @Test("A blank alias is remotedb, and an alias is trimmed") + func aliases() throws { + #expect(try QuackConnectPlan(QuackConnectRequest(host: "h", alias: " ", token: "tok")).alias == "remotedb") + let plan = try QuackConnectPlan(QuackConnectRequest(host: "h", alias: " lake ", token: "tok")) + #expect(plan.alias == "lake") + #expect(statements(plan).last == "USE \"lake\"") + } + + @Test("A bare IPv6 literal is bracketed, as Quack reads its first colon as the port") + func ipv6() throws { + #expect(try attach(QuackConnectPlan(QuackConnectRequest(host: "::1", token: "tok"))) == "ATTACH 'quack:[::1]:9494' AS \"remotedb\" (SECRET tablepro_quack)") + #expect(try attach(QuackConnectPlan(QuackConnectRequest(host: "[::1]", token: "tok"))) == "ATTACH 'quack:[::1]:9494' AS \"remotedb\" (SECRET tablepro_quack)") + #expect(try attach(QuackConnectPlan(QuackConnectRequest(host: "fe80::1", token: "tok"))) == "ATTACH 'quack:[fe80::1]:9494' AS \"remotedb\" (SECRET tablepro_quack)") + } + + // MARK: Quoting + + @Test("A hostile host, alias and token stay inside their literals") + func hostileValuesAreQuoted() throws { + let plan = try QuackConnectPlan(QuackConnectRequest( + host: "evil'); DROP TABLE t; --", + alias: "a\"b; DROP TABLE t", + token: "t'); DROP TABLE t; --" + )) + #expect(statements(plan) == [ + "CREATE OR REPLACE TEMPORARY SECRET tablepro_quack (TYPE quack, TOKEN 't''); DROP TABLE t; --')", + "ATTACH 'quack:evil''); DROP TABLE t; --:9494' AS \"a\"\"b; DROP TABLE t\" (SECRET tablepro_quack)", + dropSecret, + "USE \"a\"\"b; DROP TABLE t\"" + ]) + } + + @Test("A CA path with a quote reaches the bundle step as typed") + func hostileCAPath() throws { + let plan = try direct(.verifyIdentity, ca: "/Users/o'brien/ca.pem") + #expect(plan.steps.contains(.trustCACertificate(path: "/Users/o'brien/ca.pem"))) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackExtensionLoaderTests.swift b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackExtensionLoaderTests.swift new file mode 100644 index 0000000000..a3f70ab082 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackExtensionLoaderTests.swift @@ -0,0 +1,91 @@ +import Foundation +import TableProDuckDBCore +import Testing + +private struct DuckDBText: Error, LocalizedError { + let text: String + var errorDescription: String? { text } +} + +// Measured with an empty extension directory and no route to the extension repository. +private let downloadFailed = "IO Error: Failed to download extension \"quack\" at URL " + + "\"http://127.0.0.1:1/v2.0.0/osx_arm64/quack.duckdb_extension.gz\" (ERROR Could not establish connection)" +private let installItFirst = "IO Error: Extension \"/Users/me/.duckdb/extensions/v2.0.0/osx_arm64/quack.duckdb_extension\" " + + "not found. Install it first using \"INSTALL quack\"." + +/// Fails each statement with the next error queued for it, and runs it cleanly once the queue is empty. +private final class ScriptedDuckDB: @unchecked Sendable { + private let lock = NSLock() + private var failures: [String: [any Error]] + private var statements: [String] = [] + + init(failing failures: [String: [any Error]] = [:]) { + self.failures = failures + } + + var ran: [String] { + lock.withLock { statements } + } + + func execute(_ statement: String) throws { + let failure = lock.withLock { () -> (any Error)? in + statements.append(statement) + guard var queued = failures[statement], !queued.isEmpty else { return nil } + let failure = queued.removeFirst() + failures[statement] = queued + return failure + } + if let failure { throw failure } + } +} + +struct QuackExtensionLoaderTests { + @Test("An installed extension is loaded with no INSTALL, so no network is needed") + func loadsWithoutInstalling() async throws { + let duckdb = ScriptedDuckDB() + try await QuackExtensionLoader.load("quack") { try duckdb.execute($0) } + #expect(duckdb.ran == ["LOAD quack"]) + } + + @Test("A failed LOAD installs, then loads again") + func installsAfterAFailedLoad() async throws { + let duckdb = ScriptedDuckDB(failing: ["LOAD quack": [DuckDBText(text: installItFirst)]]) + try await QuackExtensionLoader.load("quack") { try duckdb.execute($0) } + #expect(duckdb.ran == ["LOAD quack", "INSTALL quack", "LOAD quack"]) + } + + @Test("When INSTALL and LOAD both fail, INSTALL's cause is reported, not LOAD's advice to install") + func reportsTheInstallFailure() async throws { + let duckdb = ScriptedDuckDB(failing: [ + "LOAD quack": [DuckDBText(text: installItFirst), DuckDBText(text: installItFirst)], + "INSTALL quack": [DuckDBText(text: downloadFailed)] + ]) + await #expect(throws: QuackConnectError.extensionNotInstalled(name: "quack", detail: downloadFailed)) { + try await QuackExtensionLoader.load("quack") { try duckdb.execute($0) } + } + #expect(duckdb.ran == ["LOAD quack", "INSTALL quack", "LOAD quack"]) + let message = try #require(QuackConnectError.extensionNotInstalled(name: "quack", detail: downloadFailed).errorDescription) + #expect(message.contains("Failed to download extension")) + #expect(!message.contains("Install it first")) + } + + @Test("When INSTALL works and LOAD still fails, the second LOAD's error is reported") + func reportsTheLoadFailure() async { + let duckdb = ScriptedDuckDB(failing: [ + "LOAD httpfs": [DuckDBText(text: "first"), DuckDBText(text: "IO Error: wrong platform")] + ]) + await #expect(throws: QuackConnectError.extensionNotLoaded(name: "httpfs", detail: "IO Error: wrong platform")) { + try await QuackExtensionLoader.load("httpfs") { try duckdb.execute($0) } + } + #expect(duckdb.ran == ["LOAD httpfs", "INSTALL httpfs", "LOAD httpfs"]) + } + + @Test("A cancelled connect stops at once and is rethrown as it is") + func cancellationStops() async { + let duckdb = ScriptedDuckDB(failing: ["LOAD quack": [CancellationError()]]) + await #expect(throws: CancellationError.self) { + try await QuackExtensionLoader.load("quack") { try duckdb.execute($0) } + } + #expect(duckdb.ran == ["LOAD quack"]) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackTrustBundleTests.swift b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackTrustBundleTests.swift new file mode 100644 index 0000000000..52ed78e162 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProDuckDBCoreTests/QuackTrustBundleTests.swift @@ -0,0 +1,82 @@ +import Foundation +import TableProDuckDBCore +import Testing + +private let systemBundle = Data(""" + -----BEGIN CERTIFICATE----- + UFVCTElDLVJPT1Q= + -----END CERTIFICATE----- + """.utf8) + +private let privateCA = Data(""" + -----BEGIN CERTIFICATE----- + UFJJVkFURS1DQQ== + -----END CERTIFICATE----- + + """.utf8) + +struct QuackTrustBundleTests { + @Test("The bundle keeps httpfs's default and adds the CA after it, so public HTTPS still verifies") + func combinedKeepsTheDefaultFirst() throws { + let bundle = try QuackTrustBundle.combined( + defaultBundle: systemBundle, + caCertificate: privateCA, + caCertificatePath: "/Users/me/rootCA.pem" + ) + var expected = systemBundle + expected.append(UInt8(ascii: "\n")) + expected.append(privateCA) + #expect(bundle == expected) + } + + @Test("A default bundle that ends in a newline gets no second one") + func noDoubledNewline() throws { + var system = systemBundle + system.append(UInt8(ascii: "\n")) + let bundle = try QuackTrustBundle.combined(defaultBundle: system, caCertificate: privateCA, caCertificatePath: "ca.pem") + #expect(bundle == system + privateCA) + } + + @Test("With no default bundle on the Mac, the CA alone is what httpfs would have trusted") + func noDefaultBundle() throws { + let bundle = try QuackTrustBundle.combined(defaultBundle: nil, caCertificate: privateCA, caCertificatePath: "ca.pem") + #expect(bundle == privateCA) + } + + @Test("A CA after other text in its file is still PEM") + func pemAfterText() throws { + let annotated = Data("Subject: CN=Private CA\n".utf8) + privateCA + let bundle = try QuackTrustBundle.combined(defaultBundle: nil, caCertificate: annotated, caCertificatePath: "ca.pem") + #expect(bundle == annotated) + } + + @Test("A DER file, a key or an empty file is refused with the path, before httpfs reports it vaguely") + func nonPEMIsRefused() { + let der = Data([0x30, 0x82, 0x03, 0x1D, 0x30, 0x82, 0x02, 0x05, 0xA0, 0x03, 0x02, 0x01, 0x02]) + let key = Data("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n".utf8) + for file in [der, key, Data()] { + #expect(throws: QuackConnectError.caCertificateNotPEM(path: "/Users/me/ca.der")) { + try QuackTrustBundle.combined(defaultBundle: systemBundle, caCertificate: file, caCertificatePath: "/Users/me/ca.der") + } + } + } + + @Test("The not-PEM message names the field and the conversion") + func notPEMMessage() { + let message = QuackConnectError.caCertificateNotPEM(path: "/Users/me/ca.der").localizedDescription + #expect(message.contains("CA Certificate")) + #expect(message.contains("/Users/me/ca.der")) + #expect(message.contains("openssl x509 -inform der")) + } + + @Test("The default bundle is searched in httpfs's order, ending at the one macOS has") + func defaultLocationsFollowHTTPFS() { + #expect(QuackTrustBundle.defaultBundleLocations == [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/ssl/cert.pem" + ]) + } +} diff --git a/apple/Plugins/BeancountDriverPlugin/BeancountPlugin.swift b/apple/Plugins/BeancountDriverPlugin/BeancountPlugin.swift index 6052a6d968..5555ef832c 100644 --- a/apple/Plugins/BeancountDriverPlugin/BeancountPlugin.swift +++ b/apple/Plugins/BeancountDriverPlugin/BeancountPlugin.swift @@ -27,6 +27,7 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { static let fileExtensions: [String] = ["beancount"] static let brandColorHex = "#3F7D20" static let supportsForeignKeys = false + static let supportsForeignKeyDisable = false static let supportsSchemaEditing = false static let supportsDatabaseSwitching = false static let supportsSchemaSwitching = false @@ -34,6 +35,7 @@ final class BeancountPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsHealthMonitor = false static let databaseGroupingStrategy: GroupingStrategy = .flat static let tableEntityName = "Ledger Tables" + static let containerEntityName = "Ledger" static let columnTypesByCategory: [String: [String]] = [ "Integer": ["INTEGER"], "String": ["TEXT"], diff --git a/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 05d4d458ea..b0ea856105 100644 --- a/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/apple/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -36,6 +36,7 @@ nonisolated final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin static let brandColorHex = "#FFD100" static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession] static let supportsForeignKeys = false + static let supportsForeignKeyDisable = false static let supportsRoutines = true static let systemDatabaseNames: [String] = ["information_schema", "INFORMATION_SCHEMA", "system"] static let columnTypesByCategory: [String: [String]] = [ diff --git a/apple/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift b/apple/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift index 187d684180..6308705979 100644 --- a/apple/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift +++ b/apple/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift @@ -14,6 +14,10 @@ import TableProPluginKit struct DuckDBOpenSpec: Sendable, Equatable { let path: String let accessMode: DuckDBAccessMode + /// False for Remote mode, whose token and trust come from the connection form alone. Loaded, a secret + /// the user stored in `~/.duckdb` for the server stood in for an empty Token, and its pin replaced the + /// CA and name check of the SSL Mode the form chose (measured). + var allowsPersistentSecrets = true } /// Why a release did not happen, for the log line that tells a user whose lock never comes back @@ -259,6 +263,13 @@ actor DuckDBConnectionActor { String(localized: "This build of DuckDB does not accept the storage version that keeps files readable by DuckDB 1.x") ) } + if !spec.allowsPersistentSecrets { + guard duckdb_set_config(config, "allow_persistent_secrets", "false") != DuckDBError else { + throw DuckDBPluginError.connectionFailed( + String(localized: "This build of DuckDB does not accept turning off persistent secrets") + ) + } + } if spec.accessMode == .readOnly { /// Measured: a value `duckdb_set_config` rejects leaves the option unset and the open /// then succeeds read-write with writes allowed, so an unchecked return here is a diff --git a/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 886771e2d0..4e262f044a 100644 --- a/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -6,6 +6,7 @@ import CDuckDB import Foundation import os +import TableProDuckDBCore import TableProPluginKit import TableProSQLGrammar import TableProSQLQuoting @@ -31,6 +32,15 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let requiresAuthentication = false static let connectionMode: ConnectionMode = .apiOnly static let supportsHealthMonitor = false + // Remote carries its own SSL Mode: the shared one is keyed to the built-in Host, which this driver + // never dials, and every connection saved before it stores Disabled while Quack uses HTTPS. + static let supportsSSL = false + // The app forwards duckdbHost and duckdbPort and rewrites them to the loopback, so every tunnel kind + // carries a Remote connection. Local File dials nothing, and the app offers none there. + static let supportsSSH = true + // DuckDB has no switch for foreign key checks: `PRAGMA foreign_keys=OFF` is accepted and changes + // nothing, so a Drop prompt offering to ignore them would add no statement. + static let supportsForeignKeyDisable = false static let urlSchemes: [String] = ["duckdb", "quack"] static let additionalConnectionFields: [ConnectionField] = [ @@ -72,6 +82,7 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { ConnectionField( id: "duckdbToken", label: String(localized: "Token"), + required: true, fieldType: .secure, section: .authentication, hidesPassword: true, @@ -86,6 +97,34 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { section: .authentication, visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"]) ), + ConnectionField( + id: "duckdbSSLMode", + label: String(localized: "SSL Mode"), + defaultValue: QuackSSLMode.automatic.rawValue, + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption(value: QuackSSLMode.automatic.rawValue, label: String(localized: "Automatic")), + ConnectionField.DropdownOption(value: QuackSSLMode.disabled.rawValue, label: String(localized: "Disabled")), + ConnectionField.DropdownOption(value: QuackSSLMode.required.rawValue, label: String(localized: "Required (skip verify)")), + ConnectionField.DropdownOption(value: QuackSSLMode.verifyIdentity.rawValue, label: String(localized: "Verify Identity")), + ConnectionField.DropdownOption(value: QuackSSLMode.verifyFingerprint.rawValue, label: String(localized: "Verify Fingerprint")) + ]), + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"]) + ), + ConnectionField( + id: "duckdbCACertificateFilePath", + label: String(localized: "CA Certificate"), + placeholder: String(localized: "Optional"), + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "duckdbSSLMode", values: [QuackSSLMode.verifyIdentity.rawValue]) + ), + ConnectionField( + id: "duckdbCertificateFingerprint", + label: String(localized: "Certificate Fingerprint"), + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "duckdbSSLMode", values: [QuackSSLMode.verifyFingerprint.rawValue]) + ), /// Named for what it does to the file rather than to TablePro, to keep it apart from /// Safe Mode's own Read-Only level: that one is a policy this app applies to itself and /// can be changed while connected, this one is how the file is opened and is fixed for @@ -197,6 +236,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let liveConnection = DuckDBLiveConnectionBox() private let connectionActor: DuckDBConnectionActor private let idleReleaseTimer = DuckDBIdleReleaseTimer() + private let trustBundleFile = DuckDBTrustBundleFile() private let stateLock = NSLock() nonisolated(unsafe) private var _currentSchema: String = "main" nonisolated(unsafe) private var _currentDatabase: String? @@ -337,22 +377,23 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func connectRemote() async throws { - let host = (config.additionalFields["duckdbHost"] ?? "").trimmingCharacters(in: .whitespaces) - let aliasInput = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces) - let portInput = config.additionalFields["duckdbPort"] ?? "" - let token = config.additionalFields["duckdbToken"] ?? "" - - guard QuackConnectBuilder.isValidHost(host) else { - throw DuckDBPluginError.connectionFailed( - String(localized: "Host is required for a remote DuckDB connection") - ) - } - guard let port = QuackConnectBuilder.normalizedPort(portInput) else { - throw DuckDBPluginError.connectionFailed( - String(localized: "Port must be a number between 1 and 65535") - ) + let plan: QuackConnectPlan + do { + plan = try QuackConnectPlan(QuackConnectRequest( + host: config.additionalFields["duckdbHost"] ?? "", + port: config.additionalFields["duckdbPort"] ?? "", + alias: config.additionalFields["duckdbAlias"] ?? "", + token: config.additionalFields["duckdbToken"] ?? "", + sslMode: config.additionalFields["duckdbSSLMode"], + caCertificatePath: config.additionalFields["duckdbCACertificateFilePath"], + certificateFingerprint: config.additionalFields["duckdbCertificateFingerprint"], + // Through a tunnel the app has rewritten duckdbHost and duckdbPort to the loopback forward + // and keeps the server's own name here, which is the name its certificate carries. + tunneledServerHost: config.tunneledServerName + )) + } catch { + throw Self.remoteConnectFailure(error, tunneled: false) } - let alias = aliasInput.isEmpty ? "remotedb" : aliasInput let attempt = DuckDBConnectAttempt( additionalFields: config.additionalFields, interrupt: { [liveConnection] in liveConnection.interrupt() } @@ -374,27 +415,18 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await withTaskCancellationHandler( operation: { try attempt.check() - try await connectionActor.open(spec: DuckDBOpenSpec(path: ":memory:", accessMode: .readWrite)) + try await connectionActor.open(spec: DuckDBOpenSpec( + path: ":memory:", accessMode: .readWrite, allowsPersistentSecrets: false + )) try attempt.check() try await enableExtensionAutoloading(during: attempt) - try await loadQuackExtension(during: attempt) - - if !token.isEmpty { - try await executeRemoteConnectStatement( - QuackConnectBuilder.secretSQL(token: token), - during: attempt - ) + for step in plan.steps { + try await runRemoteConnectStep(step, during: attempt) } - - try await executeRemoteConnectStatement( - QuackConnectBuilder.attachSQL(host: host, port: port, alias: alias), - during: attempt - ) - try await executeRemoteConnectStatement(QuackConnectBuilder.useSQL(alias: alias), during: attempt) try attempt.finish { stateLock.withLock { _currentSchema = "main" - _currentDatabase = alias + _currentDatabase = plan.alias } } }, @@ -407,17 +439,42 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let attemptError = attempt.stop() resetCurrentPosition() await connectionActor.close() + trustBundleFile.remove() if attemptError is DuckDBConnectAttemptError { throw DuckDBPluginError.connectionFailed( String(localized: "Connecting to the remote DuckDB database timed out.") ) } if let attemptError { throw attemptError } - throw error + throw Self.remoteConnectFailure(error, tunneled: plan.isTunneled) } timeoutTask.cancel() } + private func runRemoteConnectStep(_ step: QuackConnectPlan.Step, during attempt: DuckDBConnectAttempt) async throws { + switch step { + case .loadExtension(let name): + try await QuackExtensionLoader.load(name) { statement in + try await executeRemoteConnectStatement(statement, during: attempt) + } + case .trustCACertificate(let path): + let bundlePath = try trustBundleFile.write(caCertificatePath: path) + try await executeRemoteConnectStatement( + QuackConnectPlan.setCACertFileStatement(bundlePath: bundlePath), + during: attempt + ) + case .statement(let statement): + try await executeRemoteConnectStatement(statement, during: attempt) + } + } + + /// curl's TLS texts name no field, and one of them mentions an SSH key, so they are replaced by + /// messages that name the SSL Mode, CA Certificate or Certificate Fingerprint to change. + private static func remoteConnectFailure(_ error: any Error, tunneled: Bool) -> any Error { + guard let classified = QuackConnectErrorClassifier.classify(error, tunneled: tunneled) else { return error } + return DuckDBPluginError.connectionFailed(classified.localizedDescription) + } + /// Every metadata query is anchored to the catalog, and the app seeds the browsed /// database from `currentDatabase`, so the driver has to know which catalog the file /// opened as. DuckDB names it after the file stem, which is not derivable from the @@ -511,17 +568,6 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - private func loadQuackExtension(during attempt: DuckDBConnectAttempt) async throws { - for statement in ["INSTALL quack", "LOAD quack"] { - do { - try await executeRemoteConnectStatement(statement, during: attempt) - } catch { - try attempt.check() - Self.logger.warning("DuckDB '\(statement)' failed: \(error.localizedDescription)") - } - } - } - private func executeRemoteConnectStatement( _ statement: String, during attempt: DuckDBConnectAttempt @@ -620,9 +666,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { resetCurrentPosition() let actor = connectionActor let timer = idleReleaseTimer + let trustBundle = trustBundleFile.take() Task { await timer.stop() await actor.close() + // Only once the instance is closed, so no request of it reads a bundle that is gone. + if let trustBundle { try? FileManager.default.removeItem(at: trustBundle) } } } diff --git a/apple/Plugins/DuckDBDriverPlugin/DuckDBTrustBundleFile.swift b/apple/Plugins/DuckDBDriverPlugin/DuckDBTrustBundleFile.swift new file mode 100644 index 0000000000..bd06cfd51b --- /dev/null +++ b/apple/Plugins/DuckDBDriverPlugin/DuckDBTrustBundleFile.swift @@ -0,0 +1,65 @@ +// +// DuckDBTrustBundleFile.swift +// DuckDBDriverPlugin +// + +import Foundation +import TableProDuckDBCore + +/// The file `ca_cert_file` names for a Verify Identity connection with a CA, holding httpfs's default +/// bundle and the chosen CA (`QuackTrustBundle.combined`). +/// +/// It stays for the life of the connection. httpfs caches a bundle by path, but starts a new cache when +/// its HTTP client setting changes, which the editor can do, and then reads the file again. It goes in the +/// temporary directory because it holds certificates only, never a key: a copy a crash leaves behind +/// exposes nothing, and the system clears it. +final class DuckDBTrustBundleFile: @unchecked Sendable { + private let lock = NSLock() + private var url: URL? + + func write(caCertificatePath: String) throws -> String { + let path = NSString(string: caCertificatePath).expandingTildeInPath + let caCertificate: Data + do { + caCertificate = try Data(contentsOf: URL(fileURLWithPath: path)) + } catch { + throw QuackConnectError.caCertificateUnreadable(path: path, reason: error.localizedDescription) + } + let defaultBundle = QuackTrustBundle.defaultBundleLocations.lazy + .compactMap { FileManager.default.contents(atPath: $0) } + .first + let bundle = try QuackTrustBundle.combined( + defaultBundle: defaultBundle, + caCertificate: caCertificate, + caCertificatePath: path + ) + let target = FileManager.default.temporaryDirectory + .appendingPathComponent("TablePro-DuckDB-CA-\(UUID().uuidString).pem") + do { + try bundle.write(to: target, options: .atomic) + } catch { + throw QuackConnectError.trustBundleUnwritable(reason: error.localizedDescription) + } + let previous = lock.withLock { () -> URL? in + let previous = url + url = target + return previous + } + if let previous { try? FileManager.default.removeItem(at: previous) } + return target.path + } + + /// Hands the file over, for a caller that removes it once the DuckDB instance reading it is closed. + func take() -> URL? { + lock.withLock { () -> URL? in + let current = url + url = nil + return current + } + } + + func remove() { + guard let url = take() else { return } + try? FileManager.default.removeItem(at: url) + } +} diff --git a/apple/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift b/apple/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift deleted file mode 100644 index 55a2e0cc07..0000000000 --- a/apple/Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// QuackConnectBuilder.swift -// DuckDBDriverPlugin -// - -import Foundation -import TableProSQLQuoting - -enum QuackConnectBuilder { - static let defaultPort = 9_494 - - static func isValidHost(_ host: String) -> Bool { - !host.trimmingCharacters(in: .whitespaces).isEmpty - } - - static func normalizedPort(_ raw: String) -> Int? { - let trimmed = raw.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { return defaultPort } - guard let port = Int(trimmed), (1...65_535).contains(port) else { return nil } - return port - } - - static func secretSQL(token: String) -> String { - "CREATE OR REPLACE SECRET (TYPE quack, TOKEN \(SQLQuoting.stringLiteral(token)))" - } - - static func attachSQL(host: String, port: Int, alias: String) -> String { - "ATTACH \(SQLQuoting.stringLiteral("quack:\(host):\(port)")) AS \(SQLQuoting.identifier(alias))" - } - - static func useSQL(alias: String) -> String { - "USE \(SQLQuoting.identifier(alias))" - } -} diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 32533ca037..523341bcdc 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -241,6 +241,9 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropDatabase = true static let supportsDropSchema = true + // DROP and TRUNCATE refuse a referenced table even with its constraints set to NOCHECK, and no session + // setting suspends the checks, so the option would add no statement. + static let supportsForeignKeyDisable = false static let supportsTriggers = true static let supportsRoutines = true static let supportsUserDefinedTypeBrowse = true diff --git a/apple/Plugins/MemcachedDriverPlugin/MemcachedPlugin.swift b/apple/Plugins/MemcachedDriverPlugin/MemcachedPlugin.swift index dec07c5821..8d102882db 100644 --- a/apple/Plugins/MemcachedDriverPlugin/MemcachedPlugin.swift +++ b/apple/Plugins/MemcachedDriverPlugin/MemcachedPlugin.swift @@ -27,6 +27,7 @@ final class MemcachedPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsSSL = true static let sslModesThroughTunnel: [SSLMode] = SSLMode.allCases static let tableEntityName = "Keys" + static let containerEntityName = "Server" static let supportsForeignKeyDisable = false static let supportsReadOnlyMode = true static let databaseGroupingStrategy: GroupingStrategy = .flat diff --git a/apple/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift b/apple/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift index 1a0d47ee7d..48e498e8f5 100644 --- a/apple/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift +++ b/apple/Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift @@ -25,6 +25,7 @@ final class SurrealDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let supportsSchemaEditing = false static let supportsForeignKeys = false + static let supportsForeignKeyDisable = false static let supportsTriggers = false static let supportsTriggerEditing = false static let brandColorHex = "#FF00A0" diff --git a/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index 99f35a99c7..6790d03b9d 100644 --- a/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -58,6 +58,8 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseGroupingStrategy: GroupingStrategy = .byDatabase static let containerEntityName = "Database" static let supportsForeignKeys = true + // Teradata has no session setting that suspends referential checks, so the option would add no statement. + static let supportsForeignKeyDisable = false static let supportsRoutines = true static let supportsDatabaseTriggerBrowse = true static let supportsSchemaEditing = true diff --git a/apple/TablePro/Core/Database/DatabaseDriver.swift b/apple/TablePro/Core/Database/DatabaseDriver.swift index d309c801bd..f2dae7e00a 100644 --- a/apple/TablePro/Core/Database/DatabaseDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseDriver.swift @@ -1091,6 +1091,9 @@ enum DatabaseDriverFactory { if let refusal = SSLModeNotSupportedError.refusal(for: tunneled, pluginType: type(of: plugin)) { throw refusal } + if let refusal = DriverSettingNotSupportedError.refusal(for: tunneled, pluginType: type(of: plugin)) { + throw refusal + } var additionalFields = buildAdditionalFields(for: connection, plugin: plugin) if let sslClientKeyPassphrase = ConnectionStorage.shared.loadSSLClientKeyPassphrase(for: connection.id), diff --git a/apple/TablePro/Core/Database/DatabaseManager+SSH.swift b/apple/TablePro/Core/Database/DatabaseManager+SSH.swift index 40e94cc153..629955c883 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -71,7 +71,8 @@ extension DatabaseManager { } let sshConfig = connection.resolvedSSHConfig - guard sshConfig.enabled else { return connection } + // The stored flag alone is not enough: a driver that dials no forwarded port goes around the tunnel. + guard enabledKinds.first == .ssh, sshConfig.enabled else { return connection } let storedSshPassword: String? let keyPassphrase: String? diff --git a/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 8fcfca93a4..93aead5136 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -35,6 +35,11 @@ extension DatabaseManager { let forwardEndpoint = connection.tunnelForwardEndpoint effectiveFields[DatabaseConnection.preTunnelHostKey] = forwardEndpoint.host effectiveFields[DatabaseConnection.preTunnelPortKey] = String(forwardEndpoint.port) + // A driver that dials its server from its own fields never reads the built-in host rewritten below. + if let serverFields = connection.activeServerEndpointFields { + effectiveFields[serverFields.hostFieldId] = "127.0.0.1" + effectiveFields[serverFields.portFieldId] = String(localPort) + } // An SSH forward to the server's own socket is encrypted the whole way, as the proxy's hop is. effectiveFields[DatabaseConnection.tunnelSecuresTransportKey] = listenerSecuresTransport ? "true" : nil // One forwarded port replaces the host list. An SRV host is a name the driver resolves, not a server to dial. diff --git a/apple/TablePro/Core/Database/DriverSettingNotSupportedError.swift b/apple/TablePro/Core/Database/DriverSettingNotSupportedError.swift new file mode 100644 index 0000000000..c246103982 --- /dev/null +++ b/apple/TablePro/Core/Database/DriverSettingNotSupportedError.swift @@ -0,0 +1,82 @@ +// +// DriverSettingNotSupportedError.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// A connection that sets a field the loaded driver does not know yet. +/// +/// A registry plugin updates after the app, at the next launch and only when updating is on, so an +/// app can run with a driver built before one of its settings existed. That driver connects as if the +/// field held its default, and for a security setting the default can be weaker than the user's +/// choice. A tunnel to the type's server fields is refused too: those settings are what secure the +/// forwarded hop, and an older DuckDB driver sends the token over plain HTTP to the loopback forward, +/// which the bastion carries on unencrypted. Only the fields a type's curated entry names are checked: +/// an unknown field elsewhere is just ignored, as it always was. +struct DriverSettingNotSupportedError: Error, LocalizedError, Equatable { + enum Setting: Equatable { + case field(label: String) + case tunnel + } + + let setting: Setting + let typeName: String + + static func refusal( + for connection: DatabaseConnection, + pluginType: any DriverPlugin.Type + ) -> DriverSettingNotSupportedError? { + guard let curated = PluginMetadataRegistry.shared.curatedSnapshot(for: connection.type) else { return nil } + let required = curated.capabilities.fieldsRequiringDriverSupport + guard !required.isEmpty else { return nil } + let declared = Set(pluginType.additionalConnectionFields.map(\.id)) + guard required.contains(where: { !declared.contains($0) }) else { return nil } + let typeName = connection.type.displayName + let tunneled = !connection.enabledTunnelKinds.isEmpty || connection.preTunnelHost != nil + if connection.activeServerEndpointFields != nil, tunneled { + return DriverSettingNotSupportedError(setting: .tunnel, typeName: typeName) + } + let curatedFields = curated.connection.additionalConnectionFields + for fieldId in required where !declared.contains(fieldId) { + guard let field = curatedFields.first(where: { $0.id == fieldId }), + curatedFields.isVisible(field, forValues: connection.additionalFields) + else { continue } + let value = (connection.additionalFields[fieldId] ?? "").trimmingCharacters(in: .whitespaces) + guard !value.isEmpty, value != (field.defaultValue ?? "") else { continue } + return DriverSettingNotSupportedError(setting: .field(label: field.label), typeName: typeName) + } + return nil + } + + var errorDescription: String? { + switch setting { + case .field(let label): + return String( + format: String( + localized: """ + This connection sets %1$@, which the installed %2$@ driver does not support yet. \ + Update the database driver in Settings > Extensions, then connect again. + """ + ), + label, + typeName + ) + case .tunnel: + return String( + format: String( + localized: """ + The installed %@ driver cannot connect through a tunnel yet. Update the database driver \ + in Settings > Extensions, then connect again. + """ + ), + typeName + ) + } + } +} + +extension DriverSettingNotSupportedError: ConnectionPreflightReporting { + var failedBeforeDialing: Bool { true } +} diff --git a/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift b/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift index c48a15112e..8c089bdb9f 100644 --- a/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift +++ b/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift @@ -131,7 +131,11 @@ internal enum LocalNetworkAccessDiagnosis { return bytes[12] == 127 } + // DuckDB's Remote mode dials the server its own fields name, though its type is not `.network`. private static func directHosts(of connection: DatabaseConnection) -> [String] { + if connection.activeServerEndpointFields != nil { + return [connection.serverEndpoint.host] + } let mode = PluginMetadataRegistry.shared.snapshot(for: connection.type)?.connectionMode ?? .network guard mode == .network, connection.localSocketPath == nil else { return [] } let listed = connection.hostListEndpoints.map(\.host) diff --git a/apple/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift b/apple/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift index 88adec295f..1a246c1ce0 100644 --- a/apple/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift +++ b/apple/TablePro/Core/Plugins/ConnectionField+PasswordHiding.swift @@ -32,11 +32,27 @@ extension Sequence where Element == ConnectionField { } } + /// A field whose controlling field is hidden is hidden too, whatever value that controller + /// still holds. DuckDB's Certificate Fingerprint follows SSL Mode, which only Remote shows, so + /// without this a Local row left on Verify Fingerprint would require a field nobody can see. func isVisible(_ field: ConnectionField, forValues values: [String: String]) -> Bool { + isVisible(field, forValues: values, visited: []) + } + + // A plugin can declare a cycle, so a controller already on the path decides nothing; neither + // does one the list lacks, since the form never shows it to hide. + private func isVisible( + _ field: ConnectionField, + forValues values: [String: String], + visited: Set + ) -> Bool { guard let rule = field.visibleWhen else { return true } - let defaultValue = first { $0.id == rule.fieldId }?.defaultValue ?? "" - let currentValue = values[rule.fieldId] ?? defaultValue - return rule.values.contains(currentValue) + let controller = first { $0.id == rule.fieldId } + let currentValue = values[rule.fieldId] ?? controller?.defaultValue ?? "" + guard rule.values.contains(currentValue) else { return false } + let path = visited.union([field.id]) + guard let controller, !path.contains(controller.id) else { return true } + return isVisible(controller, forValues: values, visited: path) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift index 9ac09c6037..4082433f7a 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBConnectionFields.swift @@ -47,6 +47,7 @@ extension PluginMetadataRegistry { ConnectionField( id: "duckdbToken", label: String(localized: "Token"), + required: true, fieldType: .secure, section: .authentication, hidesPassword: true, @@ -61,6 +62,34 @@ extension PluginMetadataRegistry { section: .authentication, visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"]) ), + ConnectionField( + id: "duckdbSSLMode", + label: String(localized: "SSL Mode"), + defaultValue: "automatic", + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption(value: "automatic", label: String(localized: "Automatic")), + ConnectionField.DropdownOption(value: "disabled", label: String(localized: "Disabled")), + ConnectionField.DropdownOption(value: "required", label: String(localized: "Required (skip verify)")), + ConnectionField.DropdownOption(value: "verifyIdentity", label: String(localized: "Verify Identity")), + ConnectionField.DropdownOption(value: "verifyFingerprint", label: String(localized: "Verify Fingerprint")) + ]), + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"]) + ), + ConnectionField( + id: "duckdbCACertificateFilePath", + label: String(localized: "CA Certificate"), + placeholder: String(localized: "Optional"), + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "duckdbSSLMode", values: ["verifyIdentity"]) + ), + ConnectionField( + id: "duckdbCertificateFingerprint", + label: String(localized: "Certificate Fingerprint"), + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "duckdbSSLMode", values: ["verifyFingerprint"]) + ), /// Named for what it does to the file rather than to TablePro, to keep it apart from /// Safe Mode's own Read-Only level: that one is a policy this app applies to itself /// and can be changed while connected, this one is how the file is opened and is diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift index b784f16c5f..d8106a1092 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift @@ -33,17 +33,24 @@ extension PluginMetadataRegistry { supportsSchemaSwitching: true, supportsImport: true, supportsExport: true, - supportsSSH: false, + // Remote mode dials a TCP server, so a forward can carry it. TLS is Quack's own model, set in + // the Remote fields, so the built-in SSL section never applies. + supportsSSH: true, supportsSSL: false, supportsCascadeDrop: false, - supportsForeignKeyDisable: true, + supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: false, supportsRenameColumn: true, + // httpfs checks a server against the system CA bundle when no CA is given, so a URL asking + // for TLS with no mode means a verified connection, never Required's unchecked one. + verifiesServerWithSystemTrust: true, supportsConnectionPooling: false, localFilePathField: .additionalField("duckdbFilePath"), + serverEndpointFields: ServerEndpointFields(hostFieldId: "duckdbHost", portFieldId: "duckdbPort"), + fieldsRequiringDriverSupport: ["duckdbSSLMode"], newDatabaseFileExtensions: ["duckdb", "ddb"], readOnlyEnforcement: .sessionDefault ), diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 8b8d1b8d15..6a1ed5f833 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -296,7 +296,7 @@ extension PluginMetadataRegistry { supportsSSH: true, supportsSSL: true, supportsCascadeDrop: false, - supportsForeignKeyDisable: true, + supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, @@ -593,7 +593,7 @@ extension PluginMetadataRegistry { supportsSSH: true, supportsSSL: true, supportsCascadeDrop: false, - supportsForeignKeyDisable: true, + supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: true, requiresReconnectForDatabaseSwitch: false, diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift index a2624dc27e..34a12991d1 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -117,6 +117,17 @@ struct PluginMetadataSnapshot: Sendable { /// network, which is what makes it the test for "can this connection name a remote file". var localFilePathField: LocalFilePathField? + /// The plugin fields that name the server, for a type the built-in Host and Port do not + /// reach. Nil for every type whose driver dials `config.host`, which is what keeps a tunnel + /// forwarding and rewriting the built-in endpoint there. + var serverEndpointFields: ServerEndpointFields? + + /// Fields a driver older than this app ignores to the user's cost. A connection that sets one + /// away from its default, while it is in use, is refused when the loaded driver does not + /// declare it: DuckDB's SSL Mode read by a driver from before it existed would connect as + /// Automatic, which is plain HTTP on a loopback host, however the user set it. + var fieldsRequiringDriverSupport: [String] = [] + /// The extensions a new database file can be named with, the first one preferred. Empty /// when the driver refuses a path with nothing at it, as Beancount does, so the form offers /// no way to name one. DuckDB lists only its own format: a missing Parquet or CSV path is @@ -621,6 +632,14 @@ final class PluginMetadataRegistry: @unchecked Sendable { /// /// This is the only way to read a snapshot for a `DatabaseType`. `pluginTypeId` answers a /// different question, "which plugin serves this type", and belongs to driver lookup alone. + /// The entry the app ships for a type, kept after a plugin registers over it: what a driver + /// built with this app declares, against which an older loaded driver can be told apart. + func curatedSnapshot(for databaseType: DatabaseType) -> PluginMetadataSnapshot? { + lock.lock() + defer { lock.unlock() } + return defaultSnapshots[databaseType.rawValue] + } + func snapshot(for databaseType: DatabaseType) -> PluginMetadataSnapshot? { lock.lock() defer { lock.unlock() } @@ -798,6 +817,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { filtersTypedByReadRows: existingSnapshot?.capabilities.filtersTypedByReadRows ?? false, isEngineReadOnly: existingSnapshot?.capabilities.isEngineReadOnly ?? false, localFilePathField: existingSnapshot?.capabilities.localFilePathField, + serverEndpointFields: existingSnapshot?.capabilities.serverEndpointFields, + fieldsRequiringDriverSupport: existingSnapshot?.capabilities.fieldsRequiringDriverSupport ?? [], newDatabaseFileExtensions: existingSnapshot?.capabilities.newDatabaseFileExtensions ?? [], supportsRemoteDatabaseFile: existingSnapshot?.capabilities .supportsRemoteDatabaseFile ?? false, diff --git a/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift b/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift index 80557e95c4..de4a286fd2 100644 --- a/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift +++ b/apple/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift @@ -92,9 +92,10 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { } private static func details(for connection: DatabaseConnection) -> [String] { + let server = connection.serverEndpoint var details: [String] = [ connection.localSocketFile.map { String(format: String(localized: "Socket: %@"), $0) } - ?? String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") + ?? String(format: String(localized: "Host: %@"), "\(server.host):\(server.port)") ] if !connection.username.isEmpty { details.append(String(format: String(localized: "User: %@"), connection.username)) diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionScriptContext.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionScriptContext.swift index 595977c1ba..4d8e49106b 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionScriptContext.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionScriptContext.swift @@ -224,8 +224,8 @@ extension ConnectionScriptContext { self.init( connectionId: connection.id, connectionName: connection.name, - host: connection.preTunnelHost ?? connection.host, - port: connection.preTunnelPort ?? connection.port, + host: connection.preTunnelHost ?? connection.serverEndpoint.host, + port: connection.preTunnelPort ?? connection.serverEndpoint.port, user: ConnectionCredentialResolver.resolveUsername(for: connection), database: connection.database ) diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index bfd0fd4e93..3fe3ab7300 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProConnectionLibrary import TableProPluginKit import TableProSSHConfig @@ -23,7 +24,7 @@ struct ConnectionURLFormatter { } if connection.type == .duckdb { - return formatDuckDB(connection) + return formatDuckDB(connection, sshPassword: sshPassword) } let ssh = connection.resolvedSSHConfig @@ -61,9 +62,9 @@ struct ConnectionURLFormatter { return "sqlite://\(database)" } - private static func formatDuckDB(_ connection: DatabaseConnection) -> String { + private static func formatDuckDB(_ connection: DatabaseConnection, sshPassword: String?) -> String { if connection.additionalFields["duckdbMode"] == "remote" { - return formatDuckDBRemote(connection) + return formatDuckDBRemote(connection, sshPassword: sshPassword) } let database = connection.additionalFields["duckdbFilePath"].flatMap { $0.isEmpty ? nil : $0 } ?? connection.database @@ -73,16 +74,43 @@ struct ConnectionURLFormatter { return "duckdb://\(database)" } - private static func formatDuckDBRemote(_ connection: DatabaseConnection) -> String { + private static func formatDuckDBRemote(_ connection: DatabaseConnection, sshPassword: String?) -> String { let host = connection.additionalFields["duckdbHost"] ?? "" - var url = "quack://\(host)" - if let portString = connection.additionalFields["duckdbPort"], - let port = Int(portString.trimmingCharacters(in: .whitespaces)) { + let port = connection.additionalFields["duckdbPort"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + let alias = connection.additionalFields["duckdbAlias"] ?? "" + + // The SSH shape names the server in Host and Port, so the row's own fields stand in for the placeholder there. + let ssh = connection.resolvedSSHConfig + if ssh.enabled { + var server = connection + server.host = host + server.port = port ?? connection.type.defaultPort + server.database = alias + server.username = "" + return formatSSH( + server, sshConfig: ssh, scheme: "quack", defaultPort: connection.type.defaultPort, + password: nil, sshPassword: sshPassword + ) + } + + var url = "quack://\(HostListEndpoint.urlHost(host))" + if let port { url += ":\(port)" } - let alias = connection.additionalFields["duckdbAlias"] ?? "" if !alias.isEmpty { - url += "/\(alias)" + // Encoded as the other formats' database is: a raw `#` or `?` would end the path and drop the + // TLS parameters after it, and the copy would reopen as Automatic. + url += "/\(alias.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? alias)" + } + var params: [String] = [] + if let sslParam = sslModeParam(for: connection) { + params.append("sslmode=\(sslParam)") + } + if let fingerprintParam = DuckDBSSLModeField.fingerprintParam(for: connection.additionalFields) { + params.append(fingerprintParam) + } + if !params.isEmpty { + url += "?" + params.joined(separator: "&") } return url } @@ -124,7 +152,7 @@ struct ConnectionURLFormatter { } result += "@" } - result += ssh.host + result += HostListEndpoint.urlHost(ssh.host) if let port = ssh.port, port != 22 { result += ":\(port)" } @@ -139,7 +167,7 @@ struct ConnectionURLFormatter { result += "@" } - result += connection.host + result += HostListEndpoint.urlHost(connection.host) if connection.port != defaultPort { result += ":\(connection.port)" } @@ -151,7 +179,9 @@ struct ConnectionURLFormatter { sshPathComponent = redisPath(for: connection) } sshPathComponent += catalogSchemaSuffix(for: connection, path: sshPathComponent) - result += "/\(sshPathComponent)" + // As the direct form encodes its path. A raw `?` or `#` here ended the path and dropped every + // parameter after it, TLS included, and the parser takes the last `@` as the end of the login. + result += "/\(sshPathComponent.addingPercentEncoding(withAllowedCharacters: sshPathAllowed) ?? sshPathComponent)" let query = buildQueryString(connection, sshConfig: ssh) if !query.isEmpty { @@ -188,7 +218,7 @@ struct ConnectionURLFormatter { let mongoHosts = connection.additionalFields["mongoHosts"], !mongoHosts.isEmpty { result += mongoHosts } else { - result += connection.host + result += HostListEndpoint.urlHost(connection.host) if connection.port != defaultPort { result += ":\(connection.port)" } @@ -239,6 +269,8 @@ struct ConnectionURLFormatter { } // Trino and Presto read the path as /catalog/schema, as ConnectionURLParser does. + private static let sshPathAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "@")) + private static func catalogSchemaSuffix(for connection: DatabaseConnection, path: String) -> String { guard connection.type.pluginTypeId == DatabaseType.trino.rawValue, !path.isEmpty, let schema = connection.additionalFields["trinoSchema"]?.trimmingCharacters(in: .whitespaces), @@ -283,6 +315,11 @@ struct ConnectionURLFormatter { params.append("sslmode=\(sslParam)") } + if connection.type == .duckdb, + let fingerprintParam = DuckDBSSLModeField.fingerprintParam(for: connection.additionalFields) { + params.append(fingerprintParam) + } + if let hex = colorHex(connection.color) { params.append("statusColor=\(hex)") } @@ -349,6 +386,10 @@ struct ConnectionURLFormatter { return EtcdTLSModeField.sslMode(forFieldValue: connection.additionalFields[EtcdTLSModeField.fieldId]) .map(sslModeParamValue) } + guard connection.type != .duckdb else { + return DuckDBSSLModeField.sslMode(forFieldValue: connection.additionalFields[DuckDBSSLModeField.fieldId]) + .map(sslModeParamValue) + } guard !connection.localSocketSkipsTLS else { return nil } let mode = connection.sslConfig.mode let portImpliesTLS = connection.type.impliedSSLMode(forPort: connection.port) != nil diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 299200fbf1..3c5e5913ea 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -243,7 +243,7 @@ struct ConnectionURLParser { } } let effectivePort = isSrv ? nil : port - let tls = ImportedTLS(sslMode: sslMode, disablesTLS: ext.disablesTLS, type: dbType) + let tls = ImportedTLS(sslMode: sslMode, disablesTLS: ext.disablesTLS, sslFingerprint: ext.sslFingerprint, type: dbType) return .success(ParsedConnectionURL( type: dbType, @@ -292,10 +292,10 @@ struct ConnectionURLParser { percentEncoded: Bool = false ) -> (database: String, fields: [String: String]) { let path = rawPath.hasPrefix("/") ? String(rawPath.dropFirst()) : rawPath + let decode: (String) -> String = { percentEncoded ? ($0.removingPercentEncoding ?? $0) : $0 } guard type.pluginTypeId == DatabaseType.trino.rawValue, let slash = path.firstIndex(of: "/") else { - return (path, [:]) + return (decode(path), [:]) } - let decode: (String) -> String = { percentEncoded ? ($0.removingPercentEncoding ?? $0) : $0 } let schema = decode(String(path[path.index(after: slash)...])) return (decode(String(path[.. String { + guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host } + return String(host.dropFirst().dropLast()) + } + // libpq's rules: query `host` and `port` win, and a host starting with `/` is the socket directory. private static func libpqEndpoint( in components: URLComponents, @@ -348,7 +355,7 @@ struct ConnectionURLParser { let host: String switch queryValue("host") { case .none: - host = components.host ?? "" + host = components.host.map(bareHost) ?? "" case .some(.none): return .failure(.invalidURL) case .some(.some(let raw)): @@ -545,7 +552,7 @@ struct ConnectionURLParser { let ext = parseSSHQueryString(queryString, dbType: dbType) let sslMode = ext.resolvedSSLMode(for: dbType, port: port ?? dbType.defaultPort) port = significantPort(port, for: dbType, sslMode: sslMode) - let tls = ImportedTLS(sslMode: sslMode, disablesTLS: ext.disablesTLS, type: dbType) + let tls = ImportedTLS(sslMode: sslMode, disablesTLS: ext.disablesTLS, sslFingerprint: ext.sslFingerprint, type: dbType) let catalogPath = databasePath(database, type: dbType, percentEncoded: true) database = catalogPath.database @@ -710,6 +717,7 @@ struct ConnectionURLParser { var sslMode: SSLMode? var disablesTLS = false var requestsTLS = false + var sslFingerprint: String? var tlsVerification: SSLMode? var user: String? var password: String? @@ -747,18 +755,26 @@ struct ConnectionURLParser { let disablesTLS: Bool let additionalFields: [String: String] - init(sslMode: SSLMode?, disablesTLS: Bool, type: DatabaseType) { - guard type == .etcd else { + init(sslMode: SSLMode?, disablesTLS: Bool, sslFingerprint: String?, type: DatabaseType) { + switch type { + case .etcd: + self.sslMode = nil + self.disablesTLS = false + self.additionalFields = [ + EtcdTLSModeField.fieldId: EtcdTLSModeField.fieldValue(for: sslMode ?? .disabled) + ] + // Only quack:// reaches here; duckdb:// is a file path. + case .duckdb: + self.sslMode = nil + self.disablesTLS = false + self.additionalFields = DuckDBSSLModeField.fields( + for: sslMode, disablesTLS: disablesTLS, fingerprint: sslFingerprint + ) + default: self.sslMode = sslMode self.disablesTLS = disablesTLS self.additionalFields = [:] - return } - self.sslMode = nil - self.disablesTLS = false - self.additionalFields = [ - EtcdTLSModeField.fieldId: EtcdTLSModeField.fieldValue(for: sslMode ?? .disabled) - ] } } @@ -818,6 +834,9 @@ struct ConnectionURLParser { } else if let verification = parseTLSVerification(value) { ext.tlsVerification = verification } + // Quack's own name for a pinned certificate, which no sslmode value expresses. + case "ssl_fingerprint" where dbType == .duckdb: + ext.sslFingerprint = value case "sslverification": if let verification = parseTLSVerification(value) { ext.tlsVerification = verification diff --git a/apple/TablePro/Core/Utilities/Connection/DuckDBSSLModeField.swift b/apple/TablePro/Core/Utilities/Connection/DuckDBSSLModeField.swift new file mode 100644 index 0000000000..db6e0e3ae5 --- /dev/null +++ b/apple/TablePro/Core/Utilities/Connection/DuckDBSSLModeField.swift @@ -0,0 +1,73 @@ +// +// DuckDBSSLModeField.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The `sslmode` of a `quack://` URL, carried by the DuckDB driver's own SSL Mode field rather than +/// the built-in SSL section it never reads. +/// +/// Automatic is Quack's own default and has no URL value. A pinned certificate travels as +/// `ssl_fingerprint`, Quack's own ATTACH option name, since no `sslmode` value says "pin": a copy that +/// dropped it would reopen as Automatic, which is plain HTTP on a loopback host. +internal enum DuckDBSSLModeField { + static let fieldId = "duckdbSSLMode" + static let fingerprintFieldId = "duckdbCertificateFingerprint" + static let fingerprintQueryName = "ssl_fingerprint" + + static let automatic = "automatic" + static let disabled = "disabled" + static let required = "required" + static let verifyIdentity = "verifyIdentity" + static let verifyFingerprint = "verifyFingerprint" + + // Hex digits and colons are all a fingerprint holds; anything else is escaped so it cannot start another parameter. + private static let fingerprintAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ":")) + + static func fields(for mode: SSLMode?, disablesTLS: Bool, fingerprint: String?) -> [String: String] { + let pinned = fingerprint?.trimmingCharacters(in: .whitespaces) ?? "" + guard pinned.isEmpty else { + return [fieldId: verifyFingerprint, fingerprintFieldId: pinned] + } + return [fieldId: fieldValue(for: mode, disablesTLS: disablesTLS)] + } + + static func fingerprintParam(for fields: [String: String]) -> String? { + guard fields[fieldId] == verifyFingerprint else { return nil } + let pinned = (fields[fingerprintFieldId] ?? "").trimmingCharacters(in: .whitespaces) + guard !pinned.isEmpty else { return nil } + let encoded = pinned.addingPercentEncoding(withAllowedCharacters: fingerprintAllowed) ?? pinned + return "\(fingerprintQueryName)=\(encoded)" + } + + // Quack has no opportunistic or chain-only check: Preferred falls to Required as etcd's does, and Verify CA to the + // one verifying mode, which refuses what Verify CA would refuse and more. + static func fieldValue(for mode: SSLMode?, disablesTLS: Bool) -> String { + if disablesTLS { return disabled } + switch mode { + case nil: + return automatic + case .disabled: + return disabled + case .preferred, .required: + return required + case .verifyCa, .verifyIdentity: + return verifyIdentity + } + } + + static func sslMode(forFieldValue value: String?) -> SSLMode? { + switch value { + case disabled: + return .disabled + case required: + return .required + case verifyIdentity: + return .verifyIdentity + default: + return nil + } + } +} diff --git a/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift b/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift index d89eddbee6..a84a4cd4ff 100644 --- a/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift +++ b/apple/TablePro/Models/Connection/ConnectionTunnelKind.swift @@ -146,15 +146,21 @@ extension DatabaseConnection { return capabilities.supportsRemoteDatabaseSession && capabilities.localFilePathField != nil } + /// A port-forwarding kind counts only where the driver dials the forward. A DuckDB row switched + /// back to Local File keeps the SSH server it used in Remote, and the driver opens a file: the + /// tunnel would ask for a password and then carry nothing. Such a row connects as it shows, with + /// no transport. A `.network` driver always dials its host, so its rows keep every kind they carry. var enabledTunnelKinds: [ConnectionTunnelKind] { var kinds: [ConnectionTunnelKind] = [] + let forwards = dialsForwardedEndpoint if opensRemoteDatabaseSession { kinds.append(.remoteDatabaseSession) } else if opensRemoteDatabaseFile { kinds.append(.remoteFile) - } else if resolvedSSHConfig.enabled { + } else if resolvedSSHConfig.enabled, forwards { kinds.append(.ssh) } + guard forwards else { return kinds } if isCloudflareEnabled { kinds.append(.cloudflare) } if isCloudSQLProxyEnabled { kinds.append(.cloudSQLProxy) } if isSOCKSProxyEnabled { kinds.append(.socksProxy) } diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+Destination.swift b/apple/TablePro/Models/Connection/DatabaseConnection+Destination.swift index 0b889b1960..e4bcfd6b39 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+Destination.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+Destination.swift @@ -22,9 +22,16 @@ extension DatabaseConnection { } // Host and port first, then every other server or project the connection may sign in to, so a prompt never - // hides the real host behind a value the engine ignores, or a server behind "(+N more)". + // hides the real host behind a value the engine ignores, or a server behind "(+N more)". Where plugin fields name + // the server, as DuckDB's Remote fields do, they stand in for Host, which nothing dials. var destinationSummary: String { - var servers = [host.isEmpty || host.hasPrefix("/") ? endpointDescription : HostListEndpoint(host: host, port: port).entry] + var servers: [String] + if activeServerEndpointFields != nil { + let server = serverEndpoint + servers = [HostListEndpoint(host: server.host, port: server.port).entry] + } else { + servers = [host.isEmpty || host.hasPrefix("/") ? endpointDescription : HostListEndpoint(host: host, port: port).entry] + } let account = (additionalFields["snowflakeAccount"] ?? "").trimmingCharacters(in: .whitespaces) if type.pluginTypeId == "Snowflake", !account.isEmpty { servers.append(account) @@ -40,7 +47,7 @@ extension DatabaseConnection { var seen: Set = [] let server = servers.filter { seen.insert($0).inserted }.joined(separator: ", ") let ssh = resolvedSSHConfig - guard ssh.enabled, !ssh.host.isEmpty else { return server } + guard ssh.enabled, !ssh.host.isEmpty, opensSSHServer else { return server } return String( format: String(localized: "%1$@ through the SSH server %2$@"), server, @@ -48,8 +55,8 @@ extension DatabaseConnection { ) } - // The catalog's destinations this engine reads with these settings, in form order. Host lists are named entry by - // entry above. An engine with no declared fields names every destination it carries. + // The catalog's destinations this engine reads with these settings, in form order. Host lists and server fields are + // named above. An engine with no declared fields names every destination it carries. private var activeDestinationFieldIds: [String] { let declared = PluginMetadataRegistry.shared.snapshot(for: type)?.connection.additionalConnectionFields ?? [] guard !declared.isEmpty else { @@ -57,15 +64,22 @@ extension DatabaseConnection { .filter { ConnectionFieldCatalog.trust(of: $0) == .destination } .sorted() } + let serverFieldIds = activeServerEndpointFields.map { [$0.hostFieldId, $0.portFieldId] } ?? [] return declared.compactMap { field -> String? in if case .hostList = field.fieldType { return nil } - guard ConnectionFieldCatalog.trust(of: field.id) == .destination, + guard !serverFieldIds.contains(field.id), + ConnectionFieldCatalog.trust(of: field.id) == .destination, declared.isVisible(field, forValues: additionalFields) else { return nil } return field.id } } + // A stored SSH server the connect goes around, because the driver dials no forwarded port, is not part of the route. + private var opensSSHServer: Bool { + enabledTunnelKinds.contains { $0 == .ssh || $0 == .remoteFile || $0 == .remoteDatabaseSession } + } + private var destinationTransport: ConnectionDestination.Transport { switch sslConfig.mode { case .disabled, .preferred, .required: return .unverified diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift b/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift index 6eca896f21..18b5b0f644 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+Display.swift @@ -32,6 +32,11 @@ extension DatabaseConnection { if let socketPath = localSocketFile { return (socketPath as NSString).abbreviatingWithTildeInPath } + // A DuckDB Remote row's built-in Host is a placeholder the driver never reads. + if activeServerEndpointFields != nil { + let server = serverEndpoint + return server.port == type.defaultPort ? server.host : "\(server.host):\(server.port)" + } if let hostList = hostListDescription { return hostList } @@ -62,6 +67,7 @@ extension DatabaseConnection { } private var databaseDescriptor: String? { + guard activeServerEndpointFields == nil else { return nil } guard !host.isEmpty || hostListDescription != nil || localSocketPath != nil else { return nil } switch type.pathFieldRole { case .database, .serviceName: @@ -85,6 +91,8 @@ extension DatabaseConnection { let ssh = resolvedSSHConfig guard ssh.enabled, !ssh.host.isEmpty else { return nil } guard !ssh.forwardsRemoteFile else { return nil } + // A stored tunnel the driver would go around is not opened, so the row must not name it. + guard enabledTunnelKinds.contains(.ssh) else { return nil } return String(format: String(localized: "via %@"), ssh.host) } diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift b/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift index 4d0339f2f1..ac2e63c21e 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift @@ -76,6 +76,6 @@ extension DatabaseConnection { } var timeoutEndpointName: String { - localSocketFile ?? host.nilIfEmpty ?? name + localSocketFile ?? serverEndpoint.host.nilIfEmpty ?? name } } diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift b/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift index 3b0fd8f308..87f107f29d 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift @@ -66,15 +66,59 @@ extension DatabaseConnection { .connection.additionalConnectionFields ?? [] } - /// Where a tunnel should forward to. + /// The plugin fields that hold the server this connection dials, while its settings use them. + var activeServerEndpointFields: ServerEndpointFields? { + PluginMetadataRegistry.shared.snapshot(for: type)? + .activeServerEndpointFields(forValues: additionalFields) + } + + /// Whether a TCP forward can carry this connection's traffic. A type the registry does not + /// know keeps the tunnel it always opened. A server port its driver would refuse opens no tunnel: + /// the forward would be dialed at it first, and a value past `Int32` traps in the SSH channel, while + /// the driver refuses it with a message. The form keeps the transport, so a typo loses no settings. + var dialsForwardedEndpoint: Bool { + guard serverEndpointPortIsUsable else { return false } + return PluginMetadataRegistry.shared.snapshot(for: type)? + .dialsForwardedEndpoint(forValues: additionalFields) ?? true + } + + /// The server the driver dials, as the person named it. /// - /// A tunnel carries one local port to one remote address, so a connection that names its - /// servers in a host list rather than in Host and Port has to nominate one of them: the first. - var tunnelForwardEndpoint: (host: String, port: Int) { + /// A DuckDB Remote row keeps it in its own fields while the built-in Host stays `localhost`, + /// so anything that names the server, forwards to it or diagnoses it reads this rather than + /// `host`. A connection that names its servers in a host list nominates the first, since a + /// tunnel carries one local port to one remote address. + var serverEndpoint: (host: String, port: Int) { + if let fields = activeServerEndpointFields { + return endpoint(inServerFields: fields) + } guard let first = firstHostListEntry?.endpoint else { return (host, port) } return (first.host, first.port) } + var tunnelForwardEndpoint: (host: String, port: Int) { + serverEndpoint + } + + /// Whether the server's port field holds a port the driver would dial: empty, which means the + /// field's default, or a number in range. + var serverEndpointPortIsUsable: Bool { + guard let fields = activeServerEndpointFields else { return true } + let typed = (additionalFields[fields.portFieldId] ?? "").trimmingCharacters(in: .whitespaces) + guard !typed.isEmpty else { return true } + guard let port = Int(typed) else { return false } + return (1...65_535).contains(port) + } + + // An empty port means what its placeholder shows, as the driver reads it. + private func endpoint(inServerFields fields: ServerEndpointFields) -> (host: String, port: Int) { + let serverHost = (additionalFields[fields.hostFieldId] ?? "").trimmingCharacters(in: .whitespaces) + let typed = (additionalFields[fields.portFieldId] ?? "").trimmingCharacters(in: .whitespaces) + let declaredDefault = declaredConnectionFields.first { $0.id == fields.portFieldId }?.defaultValue + let serverPort = Int(typed) ?? declaredDefault.flatMap { Int($0) } ?? type.defaultPort + return (serverHost, serverPort) + } + /// An app that predates the form's normalizing stored rows as typed, so a row can still say /// `https://` while SSL Mode is Disabled. The driver honors the scheme, and the tunnel has to /// as well once it replaces the list with its local forward. diff --git a/apple/TablePro/Models/Connection/DatabaseConnection.swift b/apple/TablePro/Models/Connection/DatabaseConnection.swift index ce5fcc7146..f981693eb3 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection.swift @@ -354,11 +354,22 @@ struct DatabaseConnection: Identifiable, Hashable { } } + /// A socket path stands in for the built-in Host and Port, so only a `.network` driver can be + /// reached through one. A server named in plugin fields, like DuckDB's Quack, listens on TCP + /// alone, and a path stored for it would send the tunnel somewhere nothing answers. var sshForwardUnixSocketPath: String? { - get { additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey]?.nilIfEmpty } + get { + guard Self.socketForwardApplies(to: type) else { return nil } + return additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey]?.nilIfEmpty + } set { additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey] = newValue ?? "" } } + static func socketForwardApplies(to type: DatabaseType) -> Bool { + guard let snapshot = PluginMetadataRegistry.shared.snapshot(for: type) else { return true } + return snapshot.connectionMode == .network + } + init( id: UUID = UUID(), name: String, diff --git a/apple/TablePro/Models/Connection/ServerEndpointFields.swift b/apple/TablePro/Models/Connection/ServerEndpointFields.swift new file mode 100644 index 0000000000..e5da10135b --- /dev/null +++ b/apple/TablePro/Models/Connection/ServerEndpointFields.swift @@ -0,0 +1,41 @@ +// +// ServerEndpointFields.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The plugin-declared fields that name the server a driver dials, for a type that keeps it +/// there instead of in the built-in Host and Port. +/// +/// DuckDB's Remote mode reads `duckdbHost` and `duckdbPort` and never `config.host`, so a tunnel +/// that forwarded the built-in Host carried nothing the driver used. Curated per type for the +/// reason `LocalFilePathField` is: an older bundle has no static to read, and the field ids it +/// already declares are enough to forward and rewrite. +struct ServerEndpointFields: Sendable, Equatable, Hashable { + let hostFieldId: String + let portFieldId: String +} + +extension PluginMetadataSnapshot { + /// The server fields these values put to use. A DuckDB row in Local File mode keeps the host + /// it had in Remote in a hidden field, and dials nothing. + func activeServerEndpointFields(forValues values: [String: String]) -> ServerEndpointFields? { + guard let endpointFields = capabilities.serverEndpointFields else { return nil } + let declared = connection.additionalConnectionFields + guard let hostField = declared.first(where: { $0.id == endpointFields.hostFieldId }), + declared.isVisible(hostField, forValues: values) + else { return nil } + return endpointFields + } + + /// Whether the driver dials a TCP endpoint that a forwarded loopback port can stand in for. + /// + /// An `.apiOnly` or `.fileBased` driver reaches its database through an SDK, a URL it builds + /// itself or a path, so a port forward there opens a tunnel nothing uses and the driver goes + /// around it. A `.network` driver always dials `config.host`. + func dialsForwardedEndpoint(forValues values: [String: String]) -> Bool { + connectionMode == .network || activeServerEndpointFields(forValues: values) != nil + } +} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 77aec8a3a9..a04c8d3aa9 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -29739,6 +29739,10 @@ } } }, + "Automatic does not work through a tunnel to %@. DuckDB would use HTTPS and check the certificate against 127.0.0.1, not against the server's name. Set SSL Mode to Verify Fingerprint or Required (skip verify), or to Disabled if the server serves plain HTTP." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Automatically check for updates" : { "localizations" : { "fr" : { @@ -35307,6 +35311,10 @@ } } }, + "Certificate Fingerprint" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Certificate Not Available" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -68644,6 +68652,22 @@ } } }, + "DuckDB could not install its %@ extension. It downloads the extension from extensions.duckdb.org the first time it connects to a remote database." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "DuckDB could not load its %@ extension." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "DuckDB could not read the CA Certificate. It reads PEM files only." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "DuckDB could not verify the server's certificate. Either no CA it trusts signed it, or it was not issued for this host name. DuckDB checks certificates against /etc/ssl/cert.pem and the CA Certificate, and does not read the macOS keychain, so a CA added in Keychain Access is not trusted." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "DuckDB database file" : { "localizations" : { "fr" : { @@ -68678,6 +68702,10 @@ } } }, + "DuckDB reported: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "duplicate" : { "localizations" : { "fr" : { @@ -163934,6 +163962,18 @@ } } }, + "Set SSL Mode to Verify Fingerprint, or to Required (skip verify) to encrypt without checking the certificate." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Set SSL Mode to Verify Identity and choose the CA that signed the certificate in CA Certificate, or set SSL Mode to Verify Fingerprint and enter the server's Certificate Fingerprint. Required (skip verify) also connects, but does not check the certificate." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Set SSL Mode to Verify Identity, or to Verify Fingerprint for a server with a self-signed certificate." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Set the comment" : { }, @@ -172658,6 +172698,8 @@ } }, "SSL Mode" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -180367,6 +180409,10 @@ }, "TablePro could not reach the Schema Registry: %@" : { + }, + "TablePro could not read the CA Certificate at %@: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro could not read the database's own name from the connection." : { "localizations" : { @@ -180418,6 +180464,10 @@ } } }, + "TablePro could not write the CA bundle DuckDB reads: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro couldn't check which rows in other tables reference these rows." : { }, @@ -185017,6 +185067,10 @@ } } }, + "The CA Certificate at %@ is not a PEM certificate. DuckDB reads PEM files only. Convert a DER file with: openssl x509 -inform der -in ca.der -out ca.pem" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -185084,6 +185138,10 @@ } } }, + "The Certificate Fingerprint is not a SHA-256 fingerprint. Enter the 64 hex digits that quack_server_list() shows on the server, with or without colons." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The change could not be applied: %@" : { "localizations" : { "fr" : { @@ -188187,6 +188245,9 @@ } } } + }, + "The installed %@ driver cannot connect through a tunnel yet. Update the database driver in Settings > Extensions, then connect again." : { + }, "The installed %1$@ does not support %2$@. Update it in Settings > Extensions." : { @@ -191724,6 +191785,10 @@ } } }, + "The server did not answer the TLS handshake. If it serves plain HTTP, set SSL Mode to Disabled." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server did not say which InfluxDB version it runs." : { "extractionState" : "manual", "generatesSymbol" : false @@ -192022,6 +192087,10 @@ } } }, + "The server uses HTTPS, and this connection sent plain HTTP." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server writes the file, so it lands where the server can reach, not on this Mac." : { "extractionState" : "stale", "localizations" : { @@ -192058,6 +192127,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The server's certificate does not match the Certificate Fingerprint. If the server has a new certificate, copy its fingerprint again from quack_server_list() on the server." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server's certificate is not issued for a server, so it cannot prove the server's identity." : { "extractionState" : "manual", "generatesSymbol" : false @@ -195664,6 +195737,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "This build of DuckDB does not accept turning off persistent secrets" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This call matches more than one overload of %@, so Oracle can't run it (PLS-00307)." : { "extractionState" : "manual", "generatesSymbol" : false @@ -196250,6 +196327,9 @@ } } } + }, + "This connection sets %1$@, which the installed %2$@ driver does not support yet. Update the database driver in Settings > Extensions, then connect again." : { + }, "This connection signs in with Kerberos, which TablePro on iPhone and iPad can't do." : { "extractionState" : "manual", @@ -203208,6 +203288,10 @@ } } }, + "Token is required. Enter the token the server was started with, from quack_serve." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Token Name" : { "localizations" : { "fr" : { @@ -212201,6 +212285,14 @@ } } }, + "Verify Fingerprint" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Verify Fingerprint needs the server's Certificate Fingerprint. Run quack_server_list() on the server to read it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Verify Identity" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -212243,6 +212335,10 @@ } } }, + "Verify Identity does not work through a tunnel. DuckDB checks the certificate against 127.0.0.1 there, not against the server's name. Set SSL Mode to Verify Fingerprint, or to Required (skip verify)." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Verify Identity through a tunnel checks the certificate against the server's host name, and %@ is an IP address. Enter the server's host name, or set SSL Mode to Verify CA." : { "extractionState" : "manual", "generatesSymbol" : false diff --git a/apple/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift b/apple/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift index efa43315d0..ed7a09912a 100644 --- a/apple/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift +++ b/apple/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProConnectionLibrary import TableProPluginKit struct ProjectFolderCandidateList: View { @@ -105,7 +106,7 @@ struct ProjectFolderCandidateRow: View { guard !parsed.host.isEmpty else { return parsed.database.isEmpty ? typeName : parsed.database } - return parsed.port.map { "\(parsed.host):\($0)" } ?? parsed.host + return parsed.port.map { HostListEndpoint(host: parsed.host, port: $0).entry } ?? parsed.host } } diff --git a/apple/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift b/apple/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift index 3d88044887..d108c2571d 100644 --- a/apple/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift +++ b/apple/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProConnectionLibrary import TableProPluginKit struct ImportFromURLSheet: View { @@ -124,8 +125,10 @@ struct ImportFromURLSheet: View { } else if let multiHost = parsed.multiHost, multiHost.contains(",") { previewRow(String(localized: "Hosts"), multiHost) } else if !parsed.host.isEmpty { - let portStr = parsed.port.map { ":\($0)" } ?? "" - previewRow(String(localized: "Host"), parsed.host + portStr) + previewRow( + String(localized: "Host"), + parsed.port.map { HostListEndpoint(host: parsed.host, port: $0).entry } ?? parsed.host + ) } if !parsed.username.isEmpty { previewRow(String(localized: "User"), parsed.username) diff --git a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift index 9c2fae37f6..2a1050d774 100644 --- a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift +++ b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift @@ -51,14 +51,19 @@ extension ConnectionFormCoordinator { /// `.ssh` and `.remoteFile` are the same SSH server carrying different cargo, and both are /// stored in `ssh.state`, so only one of them can be offered: the getter decides which by /// capability, and offering both would let the picker select `.ssh` and read back `.remoteFile`. + /// + /// A port forward is offered only where the driver dials it, the rule `enabledTunnelKinds` + /// applies at connect: DuckDB offers one in Remote and none in Local File. var availableTransports: [ConnectionTunnelKind?] { guard !network.usesLocalSocket else { return [nil] } var transports: [ConnectionTunnelKind?] = [nil] + let forwards = dialsForwardedEndpoint if supportsRemoteDatabaseFile { transports.append(.remoteFile) - } else if services.pluginManager.supportsSSH(for: network.type) { + } else if forwards, services.pluginManager.supportsSSH(for: network.type) { transports.append(.ssh) } + guard forwards else { return transports } if services.pluginManager.supportsCloudflareTunnel(for: network.type) { transports.append(.cloudflare) } @@ -84,6 +89,11 @@ extension ConnectionFormCoordinator { testResult = nil } + private var dialsForwardedEndpoint: Bool { + PluginMetadataRegistry.shared.snapshot(for: network.type)? + .dialsForwardedEndpoint(forValues: allAdditionalFieldValues) ?? true + } + var supportsRemoteDatabaseFile: Bool { services.pluginManager.supportsRemoteDatabaseFile(for: network.type) } diff --git a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index a9947a2ef6..9a269f1f66 100644 --- a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -429,6 +429,9 @@ final class ConnectionFormCoordinator: ObservableObject { } let finalId = connectionId ?? UUID() + /// A field value can take the transports away in the same edit, as DuckDB's Connection Type + /// does, and a hidden one would still be saved, named in the connection list and synced. + normalizeTransport() var edits = buildEdits() edits.additionalFields["promptForPassword"] = auth.effectivePromptForPassword ? "true" : nil @@ -1021,7 +1024,7 @@ final class ConnectionFormCoordinator: ObservableObject { customization.tagIds.append(resolved) } if parsed.type.pluginTypeId == "libSQL", !parsed.host.isEmpty { - var urlString = "https://\(parsed.host)" + var urlString = "https://\(HostListEndpoint.urlHost(parsed.host))" if let port = parsed.port { urlString += ":\(port)" } diff --git a/apple/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift b/apple/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift index 7a80ac509b..e657ce26ff 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/Transports/SSHTransportSections.swift @@ -17,7 +17,9 @@ struct SSHTransportSections: View { var body: some View { SSHServerSections(sshState: $coordinator.ssh.state) - forwardTargetSection + if DatabaseConnection.socketForwardApplies(to: coordinator.network.type) { + forwardTargetSection + } } /// A tunnel normally forwards to Host and Port; a database that only listens on a unix socket diff --git a/apple/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/apple/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift index 87682845dc..84bed140b7 100644 --- a/apple/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift +++ b/apple/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -60,6 +60,104 @@ struct DatabaseManagerTunnelTests { #expect(tunneled.passwordSource == .env(variable: "DB_PASS")) } + @Test("A tunneled DuckDB Remote row dials the forward through its own Host and Port fields") + func tunnelRewritesServerEndpointFields() { + let connection = DatabaseConnection( + name: "quack", + host: "localhost", + port: 9_494, + type: .duckdb, + additionalFields: [ + "duckdbMode": "remote", + "duckdbHost": "quack.internal", + "duckdbPort": "9500", + "duckdbAlias": "remotedb" + ] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 61_234) + + #expect(tunneled.additionalFields["duckdbHost"] == "127.0.0.1") + #expect(tunneled.additionalFields["duckdbPort"] == "61234") + #expect(tunneled.additionalFields["duckdbAlias"] == "remotedb") + #expect(tunneled.preTunnelHost == "quack.internal") + #expect(tunneled.preTunnelPort == 9_500) + #expect(tunneled.host == "127.0.0.1") + #expect(tunneled.port == 61_234) + } + + @Test("A server port the driver would refuse opens no tunnel, so nothing dials it and the driver reports it") + func unusableServerPortOpensNoTunnel() { + var ssh = SSHConfiguration() + ssh.enabled = true + ssh.host = "bastion.example.com" + ssh.username = "deploy" + for typed in ["94x4", "0", "70000", "2147483648"] { + let connection = DatabaseConnection( + name: "quack", + host: "localhost", + port: 9_494, + type: .duckdb, + sshConfig: ssh, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "quack.internal", "duckdbPort": typed] + ) + + #expect(!connection.serverEndpointPortIsUsable, "\(typed)") + #expect(connection.enabledTunnelKinds.isEmpty, "\(typed) would open a tunnel") + } + } + + @Test("An empty server port is the field's default, so the forward replaces it") + func tunnelRewritesEmptyServerPort() { + let connection = DatabaseConnection( + name: "quack", + host: "localhost", + port: 9_494, + type: .duckdb, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "quack.internal", "duckdbPort": ""] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 61_234) + + #expect(connection.serverEndpointPortIsUsable) + #expect(tunneled.additionalFields["duckdbPort"] == "61234") + } + + @Test("A tunneled network row keeps every plugin field it carries") + func tunnelLeavesNetworkFieldsAlone() { + let connection = DatabaseConnection( + name: "pg", + host: "db.internal", + port: 5_432, + type: .postgresql, + additionalFields: ["duckdbHost": "stale.example"] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 61_234) + + #expect(tunneled.additionalFields["duckdbHost"] == "stale.example") + #expect(tunneled.preTunnelHost == "db.internal") + } + + @Test("A DuckDB Local File row with a stored SSH tunnel connects without opening it") + func duckdbLocalSkipsStoredTunnel() async throws { + var connection = DatabaseConnection( + name: "file", + type: .duckdb, + additionalFields: ["duckdbMode": "local", "duckdbFilePath": "/tmp/tablepro-tests.duckdb"] + ) + connection.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.invalid")) + + let effective = try await DatabaseManager.shared.buildEffectiveConnection( + for: connection, + deadline: ConnectionDeadline(configuredSeconds: 5) + ) + + #expect(effective.host == connection.host) + #expect(effective.preTunnelHost == nil) + #expect(effective.additionalFields["duckdbFilePath"] == "/tmp/tablepro-tests.duckdb") + } + @Test("Every tunnel rewrite preserves inherited disabled and finite timeout states") func tunnelPreservesTimeoutOverrides() { let overrides: [(connect: Int?, query: Int?)] = [ diff --git a/apple/TableProTests/Core/Database/DriverSettingNotSupportedErrorTests.swift b/apple/TableProTests/Core/Database/DriverSettingNotSupportedErrorTests.swift new file mode 100644 index 0000000000..9cb4bfa67a --- /dev/null +++ b/apple/TableProTests/Core/Database/DriverSettingNotSupportedErrorTests.swift @@ -0,0 +1,121 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProSSHConfig +import Testing + +/// A DuckDB driver from before Remote mode had an SSL Mode: it declares the Remote fields it read then. +private final class DuckDBDriverBeforeSSLMode: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "DuckDB Driver" + static let pluginVersion = "1.1.0" + static let pluginDescription = "DuckDB as it shipped before the SSL Mode field" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "DuckDB" + static let databaseDisplayName = "DuckDB" + static let iconName = "duckdb-icon" + static let defaultPort = 9_494 + static let connectionMode: ConnectionMode = .apiOnly + static let additionalConnectionFields: [ConnectionField] = ["duckdbMode", "duckdbHost", "duckdbPort", "duckdbToken"] + .map { ConnectionField(id: $0, label: $0) } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + fatalError("Not used in tests") + } +} + +/// The current driver, which declares every field the curated entry lists. +private final class DuckDBDriverWithSSLMode: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "DuckDB Driver" + static let pluginVersion = "1.2.0" + static let pluginDescription = "DuckDB with the SSL Mode field" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "DuckDB" + static let databaseDisplayName = "DuckDB" + static let iconName = "duckdb-icon" + static let defaultPort = 9_494 + static let connectionMode: ConnectionMode = .apiOnly + static let additionalConnectionFields: [ConnectionField] = PluginMetadataRegistry.shared + .curatedSnapshot(for: .duckdb)?.connection.additionalConnectionFields ?? [] + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + fatalError("Not used in tests") + } +} + +struct DriverSettingNotSupportedErrorTests { + private func remote(sslMode: String?, mode: String = "remote", ssh: SSHConfiguration = SSHConfiguration()) -> DatabaseConnection { + var fields = ["duckdbMode": mode, "duckdbHost": "quack.internal", "duckdbPort": "9494"] + fields["duckdbSSLMode"] = sslMode + return DatabaseConnection( + name: "quack", host: "localhost", port: 9_494, type: .duckdb, sshConfig: ssh, additionalFields: fields + ) + } + + @Test("A driver from before SSL Mode is refused for every mode that is not Automatic, naming the setting") + func olderDriverRefusesAChosenMode() throws { + for mode in ["disabled", "required", "verifyIdentity", "verifyFingerprint"] { + let refusal = try #require( + DriverSettingNotSupportedError.refusal(for: remote(sslMode: mode), pluginType: DuckDBDriverBeforeSSLMode.self), + "\(mode) would connect as Automatic" + ) + #expect(refusal.setting == .field(label: String(localized: "SSL Mode"))) + #expect(refusal.errorDescription?.contains("DuckDB") == true) + } + } + + @Test("Automatic, or no stored mode, is what the older driver does anyway, so it connects") + func olderDriverKeepsAutomatic() { + for mode in [nil, "", "automatic"] { + #expect( + DriverSettingNotSupportedError.refusal(for: remote(sslMode: mode), pluginType: DuckDBDriverBeforeSSLMode.self) + == nil, + "\(mode ?? "nil")" + ) + } + } + + @Test("A Local File row keeps whatever its hidden Remote SSL Mode holds, since it dials nothing") + func hiddenFieldIsNotChecked() { + let local = remote(sslMode: "required", mode: "local") + #expect(DriverSettingNotSupportedError.refusal(for: local, pluginType: DuckDBDriverBeforeSSLMode.self) == nil) + } + + @Test("A driver from before SSL Mode is refused any tunnel, Automatic included, since it would send the token in plain HTTP") + func olderDriverRefusesATunnel() throws { + var ssh = SSHConfiguration() + ssh.enabled = true + ssh.host = "bastion.example.com" + ssh.username = "deploy" + for mode in [nil, "automatic"] { + let connection = remote(sslMode: mode, ssh: ssh) + let refusal = try #require( + DriverSettingNotSupportedError.refusal(for: connection, pluginType: DuckDBDriverBeforeSSLMode.self) + ) + #expect(refusal.setting == .tunnel) + } + let current = remote(sslMode: "automatic", ssh: ssh) + #expect(DriverSettingNotSupportedError.refusal(for: current, pluginType: DuckDBDriverWithSSLMode.self) == nil) + } + + @Test("A driver that declares the field is never refused") + func currentDriverConnects() { + for mode in ["disabled", "required", "verifyIdentity", "verifyFingerprint"] { + #expect( + DriverSettingNotSupportedError.refusal(for: remote(sslMode: mode), pluginType: DuckDBDriverWithSSLMode.self) + == nil, + "\(mode)" + ) + } + } + + @Test("A type whose curated entry names no such field is never refused") + func otherTypesAreUntouched() { + let connection = DatabaseConnection( + name: "pg", host: "db.internal", port: 5_432, type: .postgresql, + additionalFields: ["duckdbSSLMode": "required"] + ) + #expect(DriverSettingNotSupportedError.refusal(for: connection, pluginType: DuckDBDriverBeforeSSLMode.self) == nil) + } +} diff --git a/apple/TableProTests/Core/Database/PromptedPasswordKeyTests.swift b/apple/TableProTests/Core/Database/PromptedPasswordKeyTests.swift index 839c3be243..ad875df4ba 100644 --- a/apple/TableProTests/Core/Database/PromptedPasswordKeyTests.swift +++ b/apple/TableProTests/Core/Database/PromptedPasswordKeyTests.swift @@ -5,6 +5,7 @@ import Foundation @testable import TablePro +import TableProSSHConfig import Testing @MainActor @@ -60,6 +61,31 @@ struct PromptedPasswordKeyTests { #expect(!summary.contains("more")) } + @Test("A DuckDB Remote prompt names the server its fields hold, once, and not the Host placeholder") + func summaryNamesTheDuckDBServer() { + let connection = DatabaseConnection( + name: "Quack", + host: "localhost", + port: 9_494, + type: .duckdb, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "quack.attacker.example", "duckdbPort": "9500"] + ) + + #expect(connection.destinationSummary == "quack.attacker.example:9500") + } + + @Test("A stored SSH server the connect goes around is not named") + func summaryOmitsAnUnusedTunnel() { + var connection = DatabaseConnection( + name: "File", + type: .duckdb, + additionalFields: ["duckdbMode": "local", "duckdbFilePath": "/tmp/a.duckdb"] + ) + connection.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + + #expect(!connection.destinationSummary.contains("bastion.example.com")) + } + @Test("A Snowflake account on another engine is not named") func snowflakeAccountOnlyForSnowflake() { let connection = DatabaseConnection( diff --git a/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift b/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift index 6d05e3b46f..075f51e75a 100644 --- a/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift +++ b/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift @@ -69,6 +69,19 @@ struct LocalNetworkAccessDiagnosisTests { #expect(hops(connection).isEmpty) } + @Test("A DuckDB Remote row asks about the server its fields name, and a Local File row about none") + func duckdbRemoteProbesItsServer() { + let remote = DatabaseConnection( + name: "Quack", host: "localhost", port: 9_494, type: .duckdb, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "192.168.170.165", "duckdbPort": "9494"] + ) + var local = remote + local.additionalFields["duckdbMode"] = "local" + + #expect(hops(remote) == [LocalNetworkHop(role: .database, host: "192.168.170.165")]) + #expect(hops(local).isEmpty) + } + @Test("Behind SSH the server is asked about, never the database it forwards to") func sshProbesTheServer() { let connection = sshConnection(SSHConfiguration(enabled: true, host: "192.168.1.9", username: "deploy")) diff --git a/apple/TableProTests/Core/Plugins/ConnectionFieldTrustCoverageTests.swift b/apple/TableProTests/Core/Plugins/ConnectionFieldTrustCoverageTests.swift index 6ad8c641ca..8a84c3f491 100644 --- a/apple/TableProTests/Core/Plugins/ConnectionFieldTrustCoverageTests.swift +++ b/apple/TableProTests/Core/Plugins/ConnectionFieldTrustCoverageTests.swift @@ -19,6 +19,7 @@ private let hostOwnedKeys: [String] = [ DatabaseConnection.iamSignInFieldId, MySQLLocalSocket.fieldKey, EtcdTLSModeField.fieldId, + DuckDBSSLModeField.fieldId, RemoteSQLiteWire.backendFieldKey, RemoteSQLiteWire.tokenFieldKey, ConnectionSecretOptions.mongoSessionTokenFieldId, diff --git a/apple/TableProTests/Core/Plugins/HealthMonitorOptOutParityTests.swift b/apple/TableProTests/Core/Plugins/HealthMonitorOptOutParityTests.swift deleted file mode 100644 index 1e2c0a5139..0000000000 --- a/apple/TableProTests/Core/Plugins/HealthMonitorOptOutParityTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// HealthMonitorOptOutParityTests.swift -// TableProTests -// -// supportsHealthMonitor is declared twice, in the curated metadata table and on the plugin, and -// the plugin wins: buildMetadataSnapshot reads it straight off the DriverPlugin type, so a -// curated `false` that the plugin does not repeat is silently discarded and the app pings a -// connection it had already decided to leave alone (#2700). -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct HealthMonitorOptOutParityTests { - /// Reads the curated table, not the live registry. Other suites register synthetic engines - /// into the same shared registry, several with the monitor off, and none of them has a plugin - /// source to find: reading the live snapshots made this fail or pass by suite order. - @Test("every type curated as health-monitor-free says so on its plugin too") - func curatedOptOutsAreDeclaredOnTheirPlugins() throws { - let optedOut = PluginMetadataRegistry.shared.builtInDefaults() - .filter { $0.snapshot.supportsHealthMonitor == false } - .map(\.typeId) - #expect(!optedOut.isEmpty, "The curated table opts at least SQLite and DuckDB out") - - let sources = try Self.pluginSources() - var missing: [String] = [] - for typeId in optedOut { - guard let source = sources.first(where: { $0.declaresTypeId(typeId) }) else { - missing.append("\(typeId) (no plugin source declares this type id)") - continue - } - if !source.text.contains("static let supportsHealthMonitor = false") { - missing.append("\(typeId) (\(source.url.lastPathComponent))") - } - } - - #expect( - missing.isEmpty, - """ - The plugin is the authoritative side: PluginMetadataRegistry.buildMetadataSnapshot copies \ - supportsHealthMonitor off the DriverPlugin type, so these keep a 30-second ping the \ - curated table already turned off: \(missing.sorted()) - """ - ) - } - - private struct PluginSource { - let url: URL - let text: String - - func declaresTypeId(_ id: String) -> Bool { - text.contains("static let databaseTypeId = \"\(id)\"") - } - } - - private static func pluginSources(file: StaticString = #filePath) throws -> [PluginSource] { - let root = try repositoryRoot(file: file).appendingPathComponent("Plugins") - guard let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { - throw ParityError.sourcesNotFound - } - var sources: [PluginSource] = [] - for case let url as URL in walker where url.pathExtension == "swift" { - guard let text = try? String(contentsOf: url, encoding: .utf8), - text.contains("static let databaseTypeId = ") - else { continue } - sources.append(PluginSource(url: url, text: text)) - } - guard !sources.isEmpty else { throw ParityError.sourcesNotFound } - return sources - } - - private static func repositoryRoot(file: StaticString) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory.appendingPathComponent("Plugins/TableProPluginKit/DriverPlugin.swift") - if FileManager.default.fileExists(atPath: candidate.path) { return directory } - directory = directory.deletingLastPathComponent() - } - throw ParityError.sourcesNotFound - } - - private enum ParityError: Error { - case sourcesNotFound - } -} diff --git a/apple/TableProTests/Core/Plugins/PasswordHidingTests.swift b/apple/TableProTests/Core/Plugins/PasswordHidingTests.swift index 47cfba60a0..af64702f9f 100644 --- a/apple/TableProTests/Core/Plugins/PasswordHidingTests.swift +++ b/apple/TableProTests/Core/Plugins/PasswordHidingTests.swift @@ -206,4 +206,77 @@ struct PluginManagerPasswordHidingTests { #expect(hides("Spanner", ["spAuthMethod": "adc"])) #expect(hides("Snowflake", ["snowflakeAuthMethod": "externalBrowser"])) } + + private func chainedFields() -> [ConnectionField] { + [ + ConnectionField( + id: "mode", + label: "Mode", + defaultValue: "local", + fieldType: .dropdown(options: [.init(value: "local", label: "local"), .init(value: "remote", label: "remote")]), + section: .authentication + ), + ConnectionField( + id: "tls", + label: "TLS", + defaultValue: "automatic", + fieldType: .dropdown(options: [.init(value: "automatic", label: "automatic"), .init(value: "pinned", label: "pinned")]), + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "mode", values: ["remote"]) + ), + ConnectionField( + id: "pin", + label: "Pin", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "tls", values: ["pinned"]) + ), + ] + } + + @Test("A field whose controller is hidden is hidden, whatever value the controller keeps") + func visibilityFollowsAHiddenController() throws { + let fields = chainedFields() + let pin = try #require(fields.first { $0.id == "pin" }) + + #expect(fields.isVisible(pin, forValues: ["mode": "remote", "tls": "pinned"])) + #expect(!fields.isVisible(pin, forValues: ["mode": "local", "tls": "pinned"])) + #expect(!fields.isVisible(pin, forValues: ["tls": "pinned"]), "The controller's default hides it too") + #expect(!fields.isVisible(pin, forValues: ["mode": "remote", "tls": "automatic"])) + } + + @Test("A cycle of visibility rules ends, and a controller the list lacks decides nothing") + func visibilityStopsOnACycleAndAMissingController() throws { + let cycle = [ + ConnectionField(id: "a", label: "A", visibleWhen: FieldVisibilityRule(fieldId: "b", values: ["on"])), + ConnectionField(id: "b", label: "B", visibleWhen: FieldVisibilityRule(fieldId: "a", values: ["on"])), + ConnectionField(id: "c", label: "C", visibleWhen: FieldVisibilityRule(fieldId: "c", values: ["on"])), + ] + let first = try #require(cycle.first) + let selfControlled = try #require(cycle.last) + + #expect(cycle.isVisible(first, forValues: ["a": "on", "b": "on"])) + #expect(!cycle.isVisible(first, forValues: ["a": "off", "b": "on"])) + #expect(cycle.isVisible(selfControlled, forValues: ["c": "on"])) + + let orphan = ConnectionField(id: "d", label: "D", visibleWhen: FieldVisibilityRule(fieldId: "elsewhere", values: ["on"])) + #expect([orphan].isVisible(orphan, forValues: ["elsewhere": "on"])) + #expect(![orphan].isVisible(orphan, forValues: [:])) + } + + @Test("DuckDB's CA and fingerprint fields show only in Remote, under the SSL Mode that uses them") + func duckdbTLSFieldsFollowRemoteMode() throws { + let fields = PluginManager.shared.additionalConnectionFields(for: .duckdb) + let caPath = try #require(fields.first { $0.id == "duckdbCACertificateFilePath" }) + let fingerprint = try #require(fields.first { $0.id == "duckdbCertificateFingerprint" }) + let sslMode = try #require(fields.first { $0.id == "duckdbSSLMode" }) + + #expect(!fields.isVisible(sslMode, forValues: ["duckdbMode": "local"])) + #expect(fields.isVisible(sslMode, forValues: ["duckdbMode": "remote"])) + #expect(fields.isVisible(caPath, forValues: ["duckdbMode": "remote", "duckdbSSLMode": "verifyIdentity"])) + #expect(!fields.isVisible(caPath, forValues: ["duckdbMode": "local", "duckdbSSLMode": "verifyIdentity"])) + #expect(fields.isVisible(fingerprint, forValues: ["duckdbMode": "remote", "duckdbSSLMode": "verifyFingerprint"])) + #expect(!fields.isVisible(fingerprint, forValues: ["duckdbMode": "local", "duckdbSSLMode": "verifyFingerprint"])) + #expect(!fields.isVisible(fingerprint, forValues: ["duckdbMode": "remote"]), "Automatic is the default") + } } diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index ee1976f948..332168c13b 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -227,6 +227,38 @@ struct PluginMetadataRegistryCuratedCapabilityTests { ) } + @Test("Curated DuckDB offers port forwards, no SSL section, and names the fields that hold its server") + func duckDBCuratedTransportCapabilities() throws { + let curated = try #require(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "DuckDB")) + + #expect(curated.capabilities.supportsSSH, "Remote mode dials a TCP server a forward can carry") + #expect(!curated.capabilities.supportsSSL, "Quack's TLS lives in the Remote fields, never the built-in section") + #expect( + curated.capabilities.serverEndpointFields + == ServerEndpointFields(hostFieldId: "duckdbHost", portFieldId: "duckdbPort") + ) + let declared = Set(curated.connection.additionalConnectionFields.map(\.id)) + #expect(declared.isSuperset(of: ["duckdbHost", "duckdbPort"])) + } + + @Test("DuckDB keeps its server fields when its plugin registers") + func duckDBKeepsItsServerEndpointFields() { + let built = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: MockDuckDBPlugin.self) + + #expect( + built.capabilities.serverEndpointFields + == ServerEndpointFields(hostFieldId: "duckdbHost", portFieldId: "duckdbPort"), + "No DriverPlugin declares them, so loading the plugin would otherwise forward the built-in Host" + ) + } + + @Test("Only DuckDB keeps its server outside the built-in Host and Port") + func onlyDuckDBNamesServerEndpointFields() { + for entry in PluginMetadataRegistry.shared.builtInDefaults() where entry.typeId != "DuckDB" { + #expect(entry.snapshot.capabilities.serverEndpointFields == nil, "\(entry.typeId)") + } + } + @Test("DuckDB keeps its file signatures when its plugin registers") func duckDBKeepsItsFileSignatures() { let registry = PluginMetadataRegistry.shared diff --git a/apple/TableProTests/Core/SSH/SSHForwardDestinationTests.swift b/apple/TableProTests/Core/SSH/SSHForwardDestinationTests.swift index 52c88c6954..332c04c22e 100644 --- a/apple/TableProTests/Core/SSH/SSHForwardDestinationTests.swift +++ b/apple/TableProTests/Core/SSH/SSHForwardDestinationTests.swift @@ -40,6 +40,21 @@ struct SSHForwardDestinationTests { #expect(connection.sshForwardDestination.isUnixSocket) } + @Test("A server named in plugin fields ignores a stored socket path and forwards to those fields") + func pluginServerFieldsIgnoreSocketPath() { + var connection = DatabaseConnection( + name: "quack", + host: "localhost", + port: 9_494, + type: .duckdb, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "quack.internal", "duckdbPort": "9500"] + ) + connection.sshForwardUnixSocketPath = "/tmp/quack.sock" + + #expect(connection.sshForwardUnixSocketPath == nil) + #expect(connection.sshForwardDestination == .tcp(host: "quack.internal", port: 9_500)) + } + @Test("Clearing the socket path falls back to host and port") func clearingRestoresTCP() { var connection = DatabaseConnection( diff --git a/apple/TableProTests/Core/Utilities/ConnectionScriptContextTests.swift b/apple/TableProTests/Core/Utilities/ConnectionScriptContextTests.swift index 8dd9f52085..d46f7c3a4e 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionScriptContextTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionScriptContextTests.swift @@ -45,6 +45,19 @@ struct ConnectionScriptContextTests { #expect(context.port == 5_439) } + @Test("A DuckDB Remote row reports the server its fields name, not the Host placeholder") + func duckdbRemoteReportsItsServer() { + let connection = DatabaseConnection( + name: "Quack", host: "localhost", port: 9_494, type: .duckdb, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "quack.internal", "duckdbPort": "9500"] + ) + + let context = ConnectionScriptContext(connection: connection) + + #expect(context.host == "quack.internal") + #expect(context.port == 9_500) + } + @Test("Each value reaches bash as one literal word however the placeholder is quoted", arguments: [ "printf '%s|' {host} {port}", "printf '%s|' \"{host}\" \"{port}\"", diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index 5b269fb091..e6d5d2f56e 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -765,6 +765,204 @@ struct ConnectionURLFormatterTests { #expect(parsed.database == "remotedb") } + private func quackConnection(_ fields: [String: String] = [:], ssh: SSHConfiguration = SSHConfiguration()) -> DatabaseConnection { + DatabaseConnection( + name: "", host: "localhost", port: 9_494, database: "", type: .duckdb, sshConfig: ssh, + sslConfig: SSLConfiguration(mode: .verifyIdentity), + additionalFields: [ + "duckdbMode": "remote", + "duckdbHost": "quack.internal", + "duckdbPort": "9495", + "duckdbAlias": "remotedb" + ].merging(fields) { _, field in field } + ) + } + + @Test("A copied quack URL keeps the SSL Mode the DuckDB driver reads when it is opened again") + func quackSSLModeRoundTrips() { + let cases: [(field: String, query: String)] = [ + ("disabled", "disable"), + ("required", "require"), + ("verifyIdentity", "verify-full") + ] + for (field, query) in cases { + let url = ConnectionURLFormatter.format( + quackConnection(["duckdbSSLMode": field]), password: nil, sshPassword: nil + ) + #expect(url == "quack://quack.internal:9495/remotedb?sslmode=\(query)") + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + continue + } + #expect(parsed.additionalFields["duckdbSSLMode"] == field, "\(url)") + #expect(parsed.sslMode == nil, "\(url) set the built-in SSL Mode DuckDB never reads") + } + } + + @Test("Automatic copies with no TLS parameter, and a stored fingerprint it does not use stays out") + func quackAutomaticCarriesNoTLSParameter() { + let conn = quackConnection([ + "duckdbSSLMode": "automatic", + "duckdbCertificateFingerprint": "EB:1C:00:8A" + ]) + let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil) + #expect(url == "quack://quack.internal:9495/remotedb") + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + return + } + #expect(parsed.additionalFields["duckdbSSLMode"] == "automatic") + #expect(parsed.additionalFields["duckdbCertificateFingerprint"] == nil) + } + + @Test("A pinned certificate copies as ssl_fingerprint and opens again pinned, never as Automatic") + func quackFingerprintRoundTrips() { + let pin = "EB:1C:23:A0:60:D0:AD:A3:E7:7C:B7:4E:9A:E8:DE:84:84:BF:5A:96:9D:43:42:36:34:AB:EE:B4:5F:F8:24:8A" + let url = ConnectionURLFormatter.format( + quackConnection(["duckdbSSLMode": "verifyFingerprint", "duckdbCertificateFingerprint": pin]), + password: nil, sshPassword: nil + ) + #expect(url == "quack://quack.internal:9495/remotedb?ssl_fingerprint=\(pin)") + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + return + } + #expect(parsed.additionalFields["duckdbSSLMode"] == "verifyFingerprint") + #expect(parsed.additionalFields["duckdbCertificateFingerprint"] == pin) + #expect(parsed.sslMode == nil) + } + + @Test("An alias holding a URL delimiter keeps its name and the SSL Mode after it", arguments: ["analysis#1", "a?b", "50%", "me@db"]) + func quackAliasWithDelimiterRoundTrips(alias: String) { + for usesSSH in [false, true] { + var sshConfig = SSHConfiguration() + sshConfig.enabled = usesSSH + sshConfig.host = "bastion.example.com" + sshConfig.username = "deploy" + let connection = quackConnection( + ["duckdbAlias": alias, "duckdbSSLMode": "verifyFingerprint", "duckdbCertificateFingerprint": "AA:BB"], + ssh: sshConfig + ) + let url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + continue + } + #expect(parsed.database == alias, "\(url)") + #expect(parsed.host == "quack.internal", "\(url)") + #expect(parsed.additionalFields["duckdbSSLMode"] == "verifyFingerprint", "\(url) lost its pin") + #expect(parsed.additionalFields["duckdbCertificateFingerprint"] == "AA:BB", "\(url)") + } + } + + @Test("An IPv6 DuckDB server keeps its address and port through a copy, direct and behind SSH", arguments: [ + ("2001:db8::1:9495", "9494"), ("2001:db8::1:9495", "9500"), ("::1", "9494") + ]) + func quackIPv6RoundTrips(host: String, port: String) { + for usesSSH in [false, true] { + var sshConfig = SSHConfiguration() + sshConfig.enabled = usesSSH + sshConfig.host = "bastion.example.com" + sshConfig.username = "deploy" + let url = ConnectionURLFormatter.format( + quackConnection(["duckdbHost": host, "duckdbPort": port], ssh: sshConfig), + password: nil, sshPassword: nil + ) + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + continue + } + #expect(parsed.host == host, "\(url)") + #expect((parsed.port ?? 9_494) == Int(port), "\(url)") + } + } + + @Test("An IPv6 host on a network type copies bracketed and reads back as the same server") + func networkIPv6RoundTrips() { + let connection = DatabaseConnection(name: "", host: "2001:db8::5", port: 5_433, database: "app", type: .postgresql) + let url = ConnectionURLFormatter.format(connection, password: nil, sshPassword: nil) + #expect(url.contains("[2001:db8::5]:5433")) + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + return + } + #expect(parsed.host == "2001:db8::5") + #expect(parsed.port == 5_433) + } + + @Test("A fingerprint that holds a separator is escaped, so it cannot add a parameter") + func quackFingerprintIsEscaped() { + let url = ConnectionURLFormatter.format( + quackConnection(["duckdbSSLMode": "verifyFingerprint", "duckdbCertificateFingerprint": "AB&sslmode=disable"]), + password: nil, sshPassword: nil + ) + #expect(url == "quack://quack.internal:9495/remotedb?ssl_fingerprint=AB%26sslmode%3Ddisable") + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + return + } + #expect(parsed.additionalFields["duckdbSSLMode"] == "verifyFingerprint") + #expect(parsed.additionalFields["duckdbCertificateFingerprint"] == "AB&sslmode=disable") + } + + @Test("A pinned DuckDB Remote row behind SSH keeps its pin in the quack+ssh URL") + func quackSSHURLKeepsFingerprint() { + var ssh = SSHConfiguration() + ssh.enabled = true + ssh.host = "bastion.example.com" + ssh.username = "deploy" + let url = ConnectionURLFormatter.format( + quackConnection(["duckdbSSLMode": "verifyFingerprint", "duckdbCertificateFingerprint": "AA:BB"], ssh: ssh), + password: nil, sshPassword: nil + ) + #expect(url.hasPrefix("quack+ssh://")) + #expect(url.contains("ssl_fingerprint=AA:BB")) + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse") + return + } + #expect(parsed.additionalFields["duckdbSSLMode"] == "verifyFingerprint") + #expect(parsed.additionalFields["duckdbCertificateFingerprint"] == "AA:BB") + } + + @Test("A DuckDB Remote row behind SSH copies as a quack+ssh URL naming its own server, and round-trips") + func quackSSHURLRoundTrips() { + var ssh = SSHConfiguration() + ssh.enabled = true + ssh.host = "bastion.example.com" + ssh.port = 2_222 + ssh.username = "deploy" + + let url = ConnectionURLFormatter.format( + quackConnection(["duckdbSSLMode": "required"], ssh: ssh), password: nil, sshPassword: nil + ) + #expect(url == "quack+ssh://deploy@bastion.example.com:2222/quack.internal:9495/remotedb?sslmode=require") + + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse"); return + } + #expect(parsed.type == .duckdb) + #expect(parsed.host == "quack.internal") + #expect(parsed.port == 9_495) + #expect(parsed.database == "remotedb") + #expect(parsed.sshHost == "bastion.example.com") + #expect(parsed.sshPort == 2_222) + #expect(parsed.sshUsername == "deploy") + #expect(parsed.additionalFields["duckdbSSLMode"] == "required") + } + + @Test("A DuckDB Local File row copies as its file whatever SSH server it still stores") + func duckdbLocalIgnoresStoredSSH() { + var ssh = SSHConfiguration() + ssh.enabled = true + ssh.host = "bastion.example.com" + let conn = DatabaseConnection( + name: "", database: "", type: .duckdb, sshConfig: ssh, + additionalFields: ["duckdbMode": "local", "duckdbFilePath": "/Users/me/analytics.duckdb"] + ) + #expect(ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil) == "duckdb:///Users/me/analytics.duckdb") + } + // MARK: - Unix socket private func socketConnection( diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index d2fc6f94dd..9831b6e2e0 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -1097,6 +1097,49 @@ struct ConnectionURLParserTests { #expect(parsed.port == nil) } + @Test("A quack sslmode sets the DuckDB SSL Mode field, never the built-in SSL Mode its driver ignores", arguments: [ + ("quack://myhost/remotedb?sslmode=disable", "disabled"), + ("quack://myhost/remotedb?sslmode=require", "required"), + ("quack://myhost/remotedb?sslmode=prefer", "required"), + ("quack://myhost/remotedb?sslmode=verify-ca", "verifyIdentity"), + ("quack://myhost/remotedb?sslmode=verify-full", "verifyIdentity"), + ("quack://myhost/remotedb?ssl=false", "disabled"), + // Asking for TLS with no mode is a checked connection, never Required's unchecked one. + ("quack://myhost/remotedb?ssl=true", "verifyIdentity"), + ("quack://myhost/remotedb?tls=require", "verifyIdentity"), + ("quack://myhost/remotedb?ssl_fingerprint=AA:BB", "verifyFingerprint"), + ("quack://myhost/remotedb", "automatic") + ]) + func testQuackSSLModeMapsToTheDriverField(url: String, field: String) { + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) did not parse"); return + } + #expect(parsed.additionalFields["duckdbSSLMode"] == field) + #expect(parsed.sslMode == nil) + #expect(!parsed.disablesTLS) + } + + @Test("A quack+ssh URL names the DuckDB server behind the SSH server") + func testQuackSSHURL() { + let result = ConnectionURLParser.parse("quack+ssh://deploy@bastion.example.com/quack.internal:9500/remotedb?sslmode=verify-full") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.type == .duckdb) + #expect(parsed.host == "quack.internal") + #expect(parsed.port == 9_500) + #expect(parsed.sshHost == "bastion.example.com") + #expect(parsed.additionalFields["duckdbSSLMode"] == "verifyIdentity") + } + + @Test("A duckdb file URL carries no SSL Mode") + func testDuckDBFileURLHasNoSSLMode() { + guard case .success(let parsed) = ConnectionURLParser.parse("duckdb:///Users/me/analytics.duckdb") else { + Issue.record("Expected success"); return + } + #expect(parsed.additionalFields["duckdbSSLMode"] == nil) + } + // MARK: - etcds TLS @Test("etcds scheme turns on the etcd TLS Mode, not the generic SSL Mode the driver ignores") diff --git a/apple/TableProTests/Models/ConnectionTunnelKindTests.swift b/apple/TableProTests/Models/ConnectionTunnelKindTests.swift index c67f19686d..b3d70b2047 100644 --- a/apple/TableProTests/Models/ConnectionTunnelKindTests.swift +++ b/apple/TableProTests/Models/ConnectionTunnelKindTests.swift @@ -11,6 +11,8 @@ import TableProSSHConfig struct ConnectionTunnelKindTests { private func connection( + type: DatabaseType = .postgresql, + fields: [String: String]? = nil, ssh: Bool = false, cloudflare: Bool = false, cloudSQLProxy: Bool = false, @@ -19,7 +21,7 @@ struct ConnectionTunnelKindTests { ) -> DatabaseConnection { DatabaseConnection( name: "T", - type: .postgresql, + type: type, sshTunnelMode: ssh ? .inline(SSHConfiguration(enabled: true, host: "ssh.example.com")) : .disabled, cloudflareTunnelMode: cloudflare ? .inline(CloudflareConfiguration(accessHostname: "db.example.com")) @@ -32,10 +34,26 @@ struct ConnectionTunnelKindTests { : .disabled, tunnelCommandMode: tunnelCommand ? .inline(TunnelCommandConfiguration(method: .kubectl, kubernetesResource: "service/pg")) - : .disabled + : .disabled, + additionalFields: fields ) } + private func connection(type: DatabaseType, fields: [String: String]? = nil, only kind: ConnectionTunnelKind) -> DatabaseConnection { + connection( + type: type, + fields: fields, + ssh: kind == .ssh, + cloudflare: kind == .cloudflare, + cloudSQLProxy: kind == .cloudSQLProxy, + socksProxy: kind == .socksProxy, + tunnelCommand: kind == .tunnelCommand + ) + } + + private static let duckdbRemote = ["duckdbMode": "remote", "duckdbHost": "quack.internal", "duckdbPort": "9494"] + private static let duckdbLocal = ["duckdbMode": "local", "duckdbFilePath": "/tmp/a.duckdb", "duckdbHost": "quack.internal"] + @Test("no tunnel has no active kind") func none() { let connection = connection() @@ -77,4 +95,40 @@ struct ConnectionTunnelKindTests { } } } + + @Test("DuckDB in Remote opens every port forward it stores, since the driver dials the forward") + func duckdbRemoteOpensItsForward() { + for kind in ConnectionTunnelKind.formToggleable { + #expect(connection(type: .duckdb, fields: Self.duckdbRemote, only: kind).activeTunnelKind == kind, "\(kind)") + } + } + + @Test("DuckDB in Local File opens none of the port forwards it still stores") + func duckdbLocalOpensNoForward() { + for kind in ConnectionTunnelKind.formToggleable { + let local = connection(type: .duckdb, fields: Self.duckdbLocal, only: kind) + #expect(local.enabledTunnelKinds.isEmpty, "\(kind) would open a tunnel a file driver goes around") + #expect(local.activeTunnelKind == nil) + } + } + + @Test("a file-based row's stored port forward is not opened") + func fileBasedRowOpensNoForward() { + let sqlite = connection(type: .sqlite, ssh: true) + #expect(sqlite.enabledTunnelKinds.isEmpty) + } + + /// PGlite offers no SSH, yet its driver dials Host and Port, so a stored tunnel keeps carrying it rather than + /// turning into a direct dial that would send its traffic in the clear. + @Test("a network row opens every kind it stores, offered or not") + func networkRowKeepsItsForward() { + for kind in ConnectionTunnelKind.formToggleable { + #expect(connection(type: .pglite, only: kind).activeTunnelKind == kind, "\(kind)") + } + } + + @Test("a type the registry does not know opens the tunnel it stores") + func unknownTypeKeepsItsForward() { + #expect(connection(type: DatabaseType(rawValue: "NoSuchEngine"), ssh: true).activeTunnelKind == .ssh) + } } diff --git a/apple/TableProTests/Models/DatabaseConnectionDisplayTests.swift b/apple/TableProTests/Models/DatabaseConnectionDisplayTests.swift index 130edeb39c..1913d049f3 100644 --- a/apple/TableProTests/Models/DatabaseConnectionDisplayTests.swift +++ b/apple/TableProTests/Models/DatabaseConnectionDisplayTests.swift @@ -244,6 +244,33 @@ struct DatabaseConnectionDisplayTests { #expect(connection.connectionSubtitle == "localhost/myapp via bastion.example.com") } + @Test("A DuckDB Remote row shows the server its fields name, through its tunnel") + func duckdbRemoteShowsItsServer() { + var sshConfig = SSHConfiguration() + sshConfig.enabled = true + sshConfig.host = "bastion.example.com" + let connection = DatabaseConnection( + name: "Quack", host: "localhost", port: 9_494, database: "remotedb", type: .duckdb, sshConfig: sshConfig, + additionalFields: ["duckdbMode": "remote", "duckdbHost": "quack.internal", "duckdbPort": "9500"] + ) + + #expect(connection.connectionSubtitle == "quack.internal:9500 via bastion.example.com") + } + + @Test("A DuckDB Local File row never names the SSH server it still stores") + func duckdbLocalHidesStoredTunnel() { + var sshConfig = SSHConfiguration() + sshConfig.enabled = true + sshConfig.host = "bastion.example.com" + let connection = DatabaseConnection( + name: "File", host: "localhost", port: 9_494, type: .duckdb, sshConfig: sshConfig, + additionalFields: ["duckdbMode": "local", "duckdbFilePath": "/tmp/a.duckdb", "duckdbHost": "quack.internal"] + ) + + #expect(!connection.connectionSubtitle.contains("bastion")) + #expect(!connection.connectionSubtitle.contains("quack.internal")) + } + @Test("Unknown future type is treated like a database role") func unknownTypeUsesDatabaseRole() { let connection = DatabaseConnection( diff --git a/apple/TableProTests/Models/TunnelForwardEndpointTests.swift b/apple/TableProTests/Models/TunnelForwardEndpointTests.swift index c534c8ede8..a1c6099fe4 100644 --- a/apple/TableProTests/Models/TunnelForwardEndpointTests.swift +++ b/apple/TableProTests/Models/TunnelForwardEndpointTests.swift @@ -20,4 +20,63 @@ struct TunnelForwardEndpointTests { #expect(connection.tunnelForwardEndpoint.host == "10.0.0.1") #expect(connection.tunnelForwardEndpoint.port == 9_201) } + + private func duckdb(mode: String, fields: [String: String] = [:]) -> DatabaseConnection { + DatabaseConnection( + name: "quack", + host: "localhost", + port: 9_494, + type: .duckdb, + additionalFields: fields.merging(["duckdbMode": mode]) { _, mode in mode } + ) + } + + @Test("A DuckDB Remote row's server is its own Host and Port fields, never the built-in placeholder") + func duckdbRemoteServerIsItsFields() { + let connection = duckdb(mode: "remote", fields: ["duckdbHost": " 192.168.1.20 ", "duckdbPort": "9500"]) + + #expect(connection.activeServerEndpointFields == ServerEndpointFields(hostFieldId: "duckdbHost", portFieldId: "duckdbPort")) + #expect(connection.serverEndpoint.host == "192.168.1.20") + #expect(connection.serverEndpoint.port == 9_500) + #expect(connection.tunnelForwardEndpoint.host == "192.168.1.20") + #expect(connection.tunnelForwardEndpoint.port == 9_500) + #expect(connection.timeoutEndpointName == "192.168.1.20") + } + + @Test("An empty DuckDB port forwards to the port its field shows") + func duckdbEmptyPortUsesTheFieldDefault() { + let connection = duckdb(mode: "remote", fields: ["duckdbHost": "quack.internal", "duckdbPort": ""]) + + #expect(connection.tunnelForwardEndpoint.host == "quack.internal") + #expect(connection.tunnelForwardEndpoint.port == 9_494) + } + + @Test("A DuckDB Local File row keeps no server, whatever its hidden Remote fields still hold") + func duckdbLocalHasNoServerFields() { + let connection = duckdb(mode: "local", fields: ["duckdbHost": "192.168.1.20", "duckdbPort": "9500"]) + + #expect(connection.activeServerEndpointFields == nil) + #expect(!connection.dialsForwardedEndpoint) + #expect(connection.serverEndpoint.host == "localhost") + } + + @Test("A network row's server stays its Host and Port") + func networkRowKeepsBuiltInEndpoint() { + let connection = DatabaseConnection(name: "pg", host: "db.internal", port: 5_433, type: .postgresql) + + #expect(connection.activeServerEndpointFields == nil) + #expect(connection.dialsForwardedEndpoint) + #expect(connection.serverEndpoint.host == "db.internal") + #expect(connection.serverEndpoint.port == 5_433) + } + + @Test("A type the registry does not know keeps forwarding its Host and Port") + func unknownTypeStillForwards() { + let connection = DatabaseConnection( + name: "future", host: "db.internal", port: 7_000, type: DatabaseType(rawValue: "NoSuchEngine") + ) + + #expect(connection.dialsForwardedEndpoint) + #expect(connection.tunnelForwardEndpoint.host == "db.internal") + } } diff --git a/apple/TableProTests/ViewModels/ConnectionFormTransportTests.swift b/apple/TableProTests/ViewModels/ConnectionFormTransportTests.swift index fd7f98e9b9..e21c04544f 100644 --- a/apple/TableProTests/ViewModels/ConnectionFormTransportTests.swift +++ b/apple/TableProTests/ViewModels/ConnectionFormTransportTests.swift @@ -181,6 +181,89 @@ struct ConnectionFormTransportTests { } } + private func duckdb(mode: String) -> ConnectionFormCoordinator { + let coordinator = coordinator(type: .duckdb) + coordinator.auth.additionalFieldValues["duckdbMode"] = mode + coordinator.auth.additionalFieldValues["duckdbHost"] = "quack.internal" + return coordinator + } + + @Test("DuckDB in Local File offers no transport and no Network tab") + func duckdbLocalHasNoNetworkTab() { + let coordinator = duckdb(mode: "local") + + #expect(coordinator.availableTransports == [nil]) + #expect(!coordinator.supportsSSL) + #expect(!coordinator.visibleTabs.contains(.network)) + } + + @Test("DuckDB in Remote offers the SSH tunnel and every other transport that forwards a port, and no SSL section") + func duckdbRemoteOffersPortForwards() { + let coordinator = duckdb(mode: "remote") + + #expect(coordinator.availableTransports == [nil, .ssh, .cloudflare, .socksProxy, .tunnelCommand]) + #expect(coordinator.visibleTabs.contains(.network)) + #expect(!coordinator.showsSSL) + } + + @Test("switching DuckDB to Local File drops the transport it had in Remote") + func duckdbLocalDropsTheRemoteTransport() { + let coordinator = duckdb(mode: "remote") + coordinator.transport = .ssh + coordinator.ssh.state.host = "bastion.example.com" + + coordinator.auth.additionalFieldValues["duckdbMode"] = "local" + coordinator.normalizeTransport() + + #expect(coordinator.transport == nil) + #expect(enabledFlags(coordinator).isEmpty) + #expect(coordinator.ssh.state.host == "bastion.example.com", "The server itself is kept for switching back") + } + + @Test("a quack+ssh URL opens DuckDB in Remote on the SSH tunnel, with the SSL Mode it names") + func quackSSHURLOpensRemoteOverSSH() throws { + let url = "quack+ssh://deploy@bastion.example.com/quack.internal:9500/remotedb?sslmode=require" + guard case .success(let parsed) = ConnectionURLParser.parse(url) else { + Issue.record("\(url) should parse") + return + } + + let coordinator = ConnectionFormCoordinator(connectionId: nil, initialParsedURL: parsed) + coordinator.start() + + #expect(coordinator.transport == .ssh, "start() must not drop a tunnel the Remote fields can use") + #expect(coordinator.ssh.state.host == "bastion.example.com") + let fields = coordinator.allAdditionalFieldValues + #expect(fields["duckdbMode"] == "remote") + #expect(fields["duckdbHost"] == "quack.internal") + #expect(fields["duckdbPort"] == "9500") + #expect(fields["duckdbSSLMode"] == "required") + } + + @Test("a DuckDB Remote tunnel command forwards to the server the General tab names") + func duckdbTunnelCommandPreviewNamesTheServer() { + let coordinator = duckdb(mode: "remote") + coordinator.auth.additionalFieldValues["duckdbPort"] = "9500" + + let endpoint = coordinator.buildEdits().applied(to: DatabaseConnection(name: "")).tunnelForwardEndpoint + + #expect(endpoint.host == "quack.internal") + #expect(endpoint.port == 9_500) + } + + /// Only a driver that dials no host loses a transport to the server-field rule, so a network + /// type offers exactly what its capabilities say, whatever its fields hold. + @Test("a type that dials its host offers a port forward wherever it supports SSH") + func networkTypesKeepTheirTransports() { + let manager = PluginManager.shared + for type in DatabaseType.allKnownTypes + where manager.connectionMode(for: type) == .network && !manager.supportsRemoteDatabaseFile(for: type) { + let available = coordinator(type: type).availableTransports + #expect(available.contains(.ssh) == manager.supportsSSH(for: type), "\(type.rawValue)") + #expect(available.contains(.socksProxy) == manager.supportsSOCKSProxy(for: type), "\(type.rawValue)") + } + } + @Test("changing the database type returns the connection to direct") func typeChangeResetsTransport() { let coordinator = coordinator() diff --git a/apple/TableProTests/ViewModels/ConnectionFormURLImportTests.swift b/apple/TableProTests/ViewModels/ConnectionFormURLImportTests.swift index 34fa795cec..20fbea7f9e 100644 --- a/apple/TableProTests/ViewModels/ConnectionFormURLImportTests.swift +++ b/apple/TableProTests/ViewModels/ConnectionFormURLImportTests.swift @@ -75,6 +75,19 @@ struct ConnectionFormURLImportTests { #expect(spelled.ssl.mode == .required) } + @Test("A bracketed IPv6 host reaches the form bare, as a driver dials it") + func ipv6HostReachesTheFormBare() throws { + let coordinator = try formImporting("postgres://alice@[2001:db8::5]:6000/sales") + #expect(coordinator.network.host == "2001:db8::5") + #expect(coordinator.network.port == "6000") + } + + @Test("A libSQL URL with an IPv6 host rebuilds a database URL that keeps its brackets") + func libSQLIPv6KeepsBrackets() throws { + let coordinator = try formImporting("libsql://[::1]:8443") + #expect(coordinator.allAdditionalFieldValues["databaseUrl"] == "https://[::1]:8443") + } + @Test("Host, credentials and database from the URL reach the form") func connectionFieldsReachTheForm() throws { let coordinator = try formImporting("postgres://alice:p%40ss@db.example.com:6000/sales") diff --git a/apple/project.yml b/apple/project.yml index 25f7b18f8c..cb69af7315 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -983,7 +983,7 @@ targets: principalClass: DuckDBPlugin dependencies: - package: TableProCore - products: [TableProSQLGrammar, TableProSQLVocabulary] + products: [TableProDuckDBCore, TableProSQLGrammar, TableProSQLVocabulary] settings: base: # Links no OpenSSL dylib, so no Libs/dylibs search path. diff --git a/apple/scripts/ci/check-capability-parity.py b/apple/scripts/ci/check-capability-parity.py new file mode 100644 index 0000000000..98b40aec87 --- /dev/null +++ b/apple/scripts/ci/check-capability-parity.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +"""Fail when a driver capability the app reads before the plugin loads differs from the plugin's own value. + +Until a plugin's code loads, the app answers from its curated entry in +TablePro/Core/Plugins/PluginMetadataRegistry*.swift. PluginMetadataRegistry.buildMetadataSnapshot then copies +these capabilities straight off the DriverPlugin type, where one the plugin does not declare is PluginKit's +default. So a value the two sides disagree on changes the UI at the first connect of a launch (#2700). + +Only a plugin's primary databaseTypeId is compared: registerVariant keeps a variant's curated entry. +A value counts only as a literal, written in place or reached through a reference to a declaration that +holds one, such as HanaMetadata.containerEntityName, cassandraCapabilities or postgreSQL.capabilities. +Any other expression, and an entry or static the check cannot find or read, is a failure. + +Usage: + check-capability-parity.py [--apple ] +""" + +import argparse +import re +import sys +from pathlib import Path + +APPLE_DIR = Path(__file__).resolve().parents[2] +REGISTRY_GLOB = "TablePro/Core/Plugins/PluginMetadataRegistry*.swift" +PLUGINKIT_DIR = "TableProPluginKit" + +# The app reads these before a plugin loads, from the curated snapshot at this path. +CAPABILITIES = { + "supportsHealthMonitor": ("supportsHealthMonitor",), + "supportsSSH": ("capabilities", "supportsSSH"), + "supportsSSL": ("capabilities", "supportsSSL"), + "supportsForeignKeyDisable": ("capabilities", "supportsForeignKeyDisable"), + "containerEntityName": ("schema", "containerEntityName"), +} + +CONSTRUCTED_TYPES = {"PluginMetadataSnapshot", "CapabilityFlags", "SchemaInfo"} +# These copy every capability above unchanged. withSwitchRouting takes another snapshot's container name. +KEEPS_CAPABILITIES = {"withIconName", "withExplainVariants", "withSystemNames", "withBranding", "withIsDownloadable"} +MAX_HOPS = 16 + +OPEN, CLOSE = "([{", ")]}" +TOP_TOKEN = re.compile(r'//|/\*|(#*)("""|")') +CODE_TOKEN = re.compile(r'//|/\*|(#*)("""|")|[()]') +COMMENT_TOKEN = re.compile(r"/\*|\*/") +BRACKET = re.compile(r"[()\[\]{}]") +CONTINUATION = re.compile(r"\s*\.") +STRING_TOKENS = {} +ENTRY = re.compile(r'\(\s*(?:typeId\s*:\s*)?"((?:[^"\\\n]|\\.)*)"\s*,\s*(?:snapshot\s*:\s*)?') +DECLARATION = re.compile(r"\b(class|struct|enum|actor|extension)\s+([A-Z]\w*)\b([^{};]*)\{") +STATIC = re.compile( + r"^[ \t]*(?:@\w+(?:\([^)\n]*\))?\s+)*" + r"(?:(?:public|private|fileprivate|internal|open|nonisolated|override|final)(?:\([^)\n]*\))?\s+)*" + r"(?:static\s+(?:let|var)|class\s+var)\s+(\w+)\s*(?::[^={\n]+?)?\s*(=|\{)", + re.M, +) +LABEL = re.compile(r"\s*([A-Za-z_]\w*)\s*:(?!:)") +CONSTRUCTOR = re.compile(r"(?:[A-Z]\w*\.)*([A-Z]\w*)\s*\(") +MODIFIER = re.compile(r"\.\s*(\w+)\s*\(") +SNAPSHOT_PARAMETER = re.compile(r"([a-z]\w*)((?:\.[A-Za-z_]\w*)+)") +CALL = re.compile(r"(?:Self\.)?([a-z]\w*)\s*\(") +NAME = re.compile(r"(?:Self\.|PluginMetadataRegistry\.)?([a-z]\w*)") +MEMBER = re.compile(r"([A-Z]\w*)\.([A-Za-z_]\w*)") +BOOL = re.compile(r"true|false") +ESCAPES = {'"': '"', "\\": "\\", "n": "\n", "t": "\t", "r": "\r", "0": "\0", "'": "'"} + + +class Unreadable(Exception): + pass + + +def mask(text): + # A bracket or a declaration inside a comment or a string must not count, and an offset into the result + # must still point at the same character of the original, where a literal's value is read. + spans = [] + i = 0 + while True: + token = TOP_TOKEN.search(text, i) + if not token: + break + if token.group(0) == "//": + end = text.find("\n", token.start()) + end = len(text) if end < 0 else end + spans.append((token.start(), end, " ")) + elif token.group(0) == "/*": + end = skip_block_comment(text, token.start()) + spans.append((token.start(), end, " ")) + else: + hashes, multiline = len(token.group(1)), token.group(2) == '"""' + close = skip_string(text, token.end(), hashes, multiline) + spans.append((token.end(), close, "x")) + end = close + len(token.group(2)) + hashes + i = end + out, emitted = [], 0 + for start, end, keep in spans: + out.append(text[emitted:start]) + out.append(re.sub(r"[^\n]", keep, text[start:end])) + emitted = end + out.append(text[emitted:]) + return "".join(out) + + +def skip_block_comment(text, i): + depth = 0 + for token in COMMENT_TOKEN.finditer(text, i): + depth += 1 if token.group(0) == "/*" else -1 + if depth == 0: + return token.end() + return len(text) + + +def skip_string(text, i, hashes, multiline): + pattern = STRING_TOKENS.get((hashes, multiline)) + if pattern is None: + close = re.escape('"""' if multiline else '"') + "#" * hashes + pattern = STRING_TOKENS[(hashes, multiline)] = re.compile(r"\\" + "#" * hashes + r"(\(|.)|" + close, re.S) + while True: + token = pattern.search(text, i) + if not token: + raise Unreadable("a string literal is never closed") + if not token.group(0).startswith("\\"): + return token.start() + i = skip_interpolation(text, token.end()) if token.group(1) == "(" else token.end() + + +def skip_interpolation(text, i): + depth = 0 + while True: + token = CODE_TOKEN.search(text, i) + if not token: + raise Unreadable("an interpolation is never closed") + lexeme = token.group(0) + if lexeme == "(": + depth += 1 + i = token.end() + elif lexeme == ")": + if depth == 0: + return token.end() + depth -= 1 + i = token.end() + elif lexeme == "//": + end = text.find("\n", token.start()) + i = len(text) if end < 0 else end + elif lexeme == "/*": + i = skip_block_comment(text, token.start()) + else: + hashes, quotes = len(token.group(1)), token.group(2) + i = skip_string(text, token.end(), hashes, quotes == '"""') + len(quotes) + hashes + + +class Source: + def __init__(self, path, root): + self.path = path + self.name = path.relative_to(root).as_posix() + self.text = path.read_text(encoding="utf-8") + try: + self.code = mask(self.text) + except Unreadable as error: + raise Unreadable(f"{self.name}: {error}") from error + + def where(self, offset): + return f"{self.name}:{self.text.count(chr(10), 0, offset) + 1}" + + +class Expr: + def __init__(self, source, start, end): + while start < end and source.code[start].isspace(): + start += 1 + while end > start and source.code[end - 1].isspace(): + end -= 1 + self.source, self.start, self.end = source, start, end + + @property + def code(self): + return self.source.code[self.start:self.end] + + @property + def text(self): + return self.source.text[self.start:self.end] + + def where(self): + return self.source.where(self.start) + + def snippet(self): + flat = " ".join(self.text.split()) + return flat if len(flat) <= 60 else flat[:57] + "..." + + +def matching(code, i): + depth = 0 + for bracket in BRACKET.finditer(code, i): + depth += 1 if bracket.group(0) in OPEN else -1 + if depth == 0: + return bracket.start() + raise Unreadable("a bracket is never closed") + + +def expression_end(code, i): + # A line break ends a Swift statement unless the next line goes on with a member, as .withSystemNames(...) does. + depth, begun = 0, False + j = i + while j < len(code): + c = code[j] + if c in OPEN: + depth += 1 + elif c in CLOSE: + if depth == 0: + return j + depth -= 1 + elif depth == 0 and c in ",;": + return j + elif depth == 0 and c == "\n" and begun and not CONTINUATION.match(code, j): + return j + begun = begun or not c.isspace() + j += 1 + return j + + +def literal(expr): + if BOOL.fullmatch(expr.code): + return expr.code == "true" + quoted = re.fullmatch(r'(#*)"([^"\n]*)"\1', expr.code) + if not quoted: + return None + hashes = quoted.group(1) + body = expr.text[len(hashes) + 1:len(expr.text) - len(hashes) - 1] + if "\\" + hashes + "(" in body: + return None + if hashes: + return body + value, k = [], 0 + while k < len(body): + if body[k] == "\\" and k + 1 < len(body) and body[k + 1] in ESCAPES: + value.append(ESCAPES[body[k + 1]]) + k += 2 + else: + value.append(body[k]) + k += 1 + return "".join(value) + + +def arguments(expr, open_at): + source = expr.source + close = matching(source.code, open_at) + if close >= expr.end: + raise Unreadable(f"{expr.where()}: `{expr.snippet()}` is never closed") + args = {} + start = open_at + 1 + while start < close: + end = expression_end(source.code, start) + if end > close: + end = close + label = LABEL.match(source.code, start, end) + if label: + name = label.group(1) + if name in args: + raise Unreadable(f"{source.where(start)}: passes {name} twice") + args[name] = Expr(source, label.end(), end) + start = end + 1 + return args, Expr(source, close + 1, expr.end) + + +def describe(value): + return ("true" if value else "false") if isinstance(value, bool) else f'"{value}"' + + +def normalized(name): + return re.sub(r"\W", "", name).lower() + + +class Curated: + def __init__(self, sources): + self.sources = sources + self.entries = {} + for source in sources: + for found in ENTRY.finditer(source.text): + quote = found.start(1) - 1 + if source.code[found.start()] != "(" or source.code[quote] != '"': + continue + # A call such as requestTemplate("Scan", ...) is not an entry; a tuple follows a bracket or a comma. + before = source.code[:found.start()].rstrip() + if before and (before[-1].isalnum() or before[-1] in "_)]?!>"): + continue + type_id = literal(Expr(source, quote, found.end(1) + 1)) + if type_id is None: + continue + end = expression_end(source.code, found.end()) + self.entries.setdefault(type_id, []).append(Expr(source, found.end(), end)) + + def resolve(self, expr, path, hops=0): + if hops > MAX_HOPS: + raise Unreadable(f"{expr.where()}: follows more than {MAX_HOPS} references") + value = literal(expr) + if value is not None: + if path: + raise Unreadable(f"{expr.where()}: `{expr.snippet()}` has no {path[0]}") + return value, expr.where() + code = expr.code + + constructed = CONSTRUCTOR.match(code) + if constructed and constructed.group(1) in CONSTRUCTED_TYPES: + args, tail = arguments(expr, expr.start + constructed.end() - 1) + self.check_tail(tail) + if not path: + raise Unreadable(f"{expr.where()}: `{expr.snippet()}` is not a literal") + if path[0] not in args: + raise Unreadable(f"{expr.where()}: {constructed.group(1)}(...) passes no {path[0]}") + return self.resolve(args[path[0]], path[1:], hops) + + parameter = SNAPSHOT_PARAMETER.fullmatch(code) + if parameter: + entry = self.entry_for_parameter(parameter.group(1), expr) + return self.resolve(entry, list(parameter.group(2)[1:].split(".")) + list(path), hops + 1) + + call = CALL.match(code) + if call: + close = matching(code, call.end() - 1) + if code[close + 1:].strip(): + raise Unreadable(f"{expr.where()}: cannot read `{expr.snippet()}` as a literal") + return self.resolve(self.function_body(call.group(1), expr), path, hops + 1) + + named = NAME.fullmatch(code) + if named: + return self.resolve(self.binding(named.group(1), expr), path, hops + 1) + + raise Unreadable(f"{expr.where()}: cannot read `{expr.snippet()}` as a literal") + + def check_tail(self, tail): + rest = tail + while rest.code: + modifier = MODIFIER.match(rest.code) + if not modifier or modifier.group(1) not in KEEPS_CAPABILITIES: + raise Unreadable(f"{rest.where()}: `{rest.snippet()}` may change a capability this check compares") + close = matching(rest.source.code, rest.start + modifier.end() - 1) + rest = Expr(rest.source, close + 1, rest.end) + + def entry_for_parameter(self, name, expr): + # KingbaseES's entry is PostgreSQL's, passed in as `postgreSQL: PluginMetadataSnapshot`. + declared = re.compile(r"\b" + re.escape(name) + r"\s*:\s*PluginMetadataSnapshot\b") + if not any(declared.search(source.code) for source in self.sources): + raise Unreadable(f"{expr.where()}: `{name}` is not a PluginMetadataSnapshot parameter") + named = [type_id for type_id in self.entries if normalized(type_id) == normalized(name)] + if len(named) != 1 or len(self.entries[named[0]]) != 1: + raise Unreadable(f"{expr.where()}: `{name}` names no single curated entry") + return self.entries[named[0]][0] + + def function_body(self, name, expr): + pattern = re.compile(r"\bfunc\s+" + re.escape(name) + r"\s*\(") + found = [(source, match) for source in self.sources for match in pattern.finditer(source.code)] + if len(found) != 1: + raise Unreadable(f"{expr.where()}: {len(found)} functions named {name}") + source, match = found[0] + parameters_end = matching(source.code, match.end() - 1) + body_open = source.code.index("{", parameters_end) + body = Expr(source, body_open + 1, matching(source.code, body_open)) + returned = re.match(r"return\b", body.code) + return Expr(source, body.start + returned.end(), body.end) if returned else body + + def binding(self, name, expr): + pattern = re.compile(r"\blet\s+" + re.escape(name) + r"\s*(?::[^=\n]+)?=") + found = [(source, match) for source in self.sources for match in pattern.finditer(source.code)] + if len(found) != 1: + raise Unreadable(f"{expr.where()}: {len(found)} let bindings named {name}") + source, match = found[0] + return Expr(source, match.end(), expression_end(source.code, match.end())) + + +def conforms_to_driver_plugin(clause): + return re.search(r"\bDriverPlugin\b", clause) is not None + + +class Declared: + def __init__(self, name, folder, source, clause): + self.name, self.folder, self.source = name, folder, source + self.is_driver = conforms_to_driver_plugin(clause) + self.statics = {} + + +def statics_in(source, body_open, body_close): + # Only the top level: a static of a nested type is not the plugin's. + code = source.code + found = [] + depth, counted = 0, body_open + 1 + for match in STATIC.finditer(code, body_open + 1, body_close): + depth += code.count("{", counted, match.start()) - code.count("}", counted, match.start()) + counted = match.start() + if depth != 0: + continue + if match.group(2) == "=": + found.append((match.group(1), Expr(source, match.end(), expression_end(source.code, match.end())))) + else: + brace = match.end() - 1 + body = Expr(source, brace + 1, matching(source.code, brace)) + returned = re.match(r"return\b", body.code) + found.append((match.group(1), Expr(source, body.start + returned.end(), body.end) if returned else body)) + return found + + +class Plugins: + def __init__(self, plugins_dir, root): + self.plugins_dir = plugins_dir + self.types = [] + self.defaults = {} + extensions = [] + for path in sorted(plugins_dir.rglob("*.swift")): + folder = path.relative_to(plugins_dir).parts[0] + # A test bundle's doubles are not what the plugin ships, and may share a real type's name. + if folder.endswith("Tests"): + continue + source = Source(path, root) + for match in DECLARATION.finditer(source.code): + body_open = match.end() - 1 + try: + body_close = matching(source.code, body_open) + members = statics_in(source, body_open, body_close) + except Unreadable as error: + raise Unreadable(f"{source.where(body_open)}: {error}") from error + if match.group(1) == "extension": + extensions.append((match.group(2), folder, match.group(3), members)) + else: + declared = Declared(match.group(2), folder, source, match.group(3)) + self.add(declared, members) + self.types.append(declared) + for name, folder, clause, members in extensions: + if name == "DriverPlugin" and folder == PLUGINKIT_DIR: + for member, expr in members: + self.defaults.setdefault(member, []).append(expr) + for declared in self.types: + if declared.name == name and declared.folder == folder: + declared.is_driver = declared.is_driver or conforms_to_driver_plugin(clause) + self.add(declared, members) + + @staticmethod + def add(declared, members): + for member, expr in members: + declared.statics.setdefault(member, []).append(expr) + + def drivers(self): + for folder in sorted(path.name for path in self.plugins_dir.iterdir() if (path / "Info.plist").is_file()): + found = [declared for declared in self.types if declared.folder == folder and declared.is_driver] + if len(found) > 1: + yield folder, None, f"{len(found)} types conform to DriverPlugin" + elif found: + yield folder, found[0], None + elif any(declared.folder == folder and "databaseTypeId" in declared.statics for declared in self.types): + yield folder, None, "declares databaseTypeId on a type that does not visibly conform to DriverPlugin" + + def find(self, name, near, expr): + found = [declared for declared in self.types if declared.name == name and declared.folder == near] + if not found: + found = [declared for declared in self.types if declared.name == name] + if len(found) != 1: + raise Unreadable(f"{expr.where()}: {len(found)} types named {name}") + return found[0] + + def static(self, declared, member, hops=0): + if hops > MAX_HOPS: + raise Unreadable(f"{declared.name}.{member} follows more than {MAX_HOPS} references") + exprs = declared.statics.get(member, []) + if len(exprs) > 1: + places = ", ".join(expr.where() for expr in exprs) + raise Unreadable(f"{declared.name} declares {member} {len(exprs)} times ({places})") + if exprs: + return self.value(exprs[0], declared, hops) + if not declared.is_driver: + raise Unreadable(f"{declared.source.name}: {declared.name} declares no static {member}") + defaults = self.defaults.get(member, []) + if len(defaults) != 1: + raise Unreadable(f"PluginKit has {len(defaults)} defaults for {member}") + value, where = self.value(defaults[0], None, hops) + return value, f"PluginKit default, {where}" + + def value(self, expr, declared, hops): + value = literal(expr) + if value is not None: + return value, expr.where() + member = MEMBER.fullmatch(expr.code) + if member and (member.group(1) != "Self" or declared): + near = declared.folder if declared else PLUGINKIT_DIR + owner = declared if member.group(1) == "Self" else self.find(member.group(1), near, expr) + return self.static(owner, member.group(2), hops + 1) + raise Unreadable(f"{expr.where()}: cannot read `{expr.snippet()}` as a literal") + + +def problems_in(apple): + registry = sorted(apple.glob(REGISTRY_GLOB)) + if not registry: + raise SystemExit(f"no {REGISTRY_GLOB} under {apple}") + try: + curated = Curated([Source(path, apple) for path in registry]) + plugins = Plugins(apple / "Plugins", apple) + except Unreadable as error: + return [str(error)], [], 0 + + unreadable, mismatches, compared = [], [], 0 + for folder, driver, reason in plugins.drivers(): + if reason: + unreadable.append(f"{folder}: {reason}") + continue + try: + type_id, _ = plugins.static(driver, "databaseTypeId") + if not isinstance(type_id, str): + raise Unreadable(f"databaseTypeId is {describe(type_id)}, not a string") + except Unreadable as error: + unreadable.append(f"{folder}: {error}") + continue + entries = curated.entries.get(type_id, []) + if len(entries) != 1: + places = "".join(f" ({entry.where()})" for entry in entries) + unreadable.append(f'{folder}: {len(entries)} curated entries for "{type_id}" in {REGISTRY_GLOB}{places}') + continue + compared += 1 + for capability, path in CAPABILITIES.items(): + try: + curated_value, curated_where = curated.resolve(entries[0], path) + plugin_value, plugin_where = plugins.static(driver, capability) + except Unreadable as error: + unreadable.append(f"{type_id} {capability}: {error}") + continue + if curated_value != plugin_value: + mismatches.append( + f"{type_id} {capability}: curated {describe(curated_value)} ({curated_where}), " + f"plugin {describe(plugin_value)} ({plugin_where})" + ) + return unreadable, mismatches, compared + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--apple", type=Path, default=APPLE_DIR, help="the apple/ folder to check") + args = parser.parse_args(argv) + + unreadable, mismatches, compared = problems_in(args.apple) + errors = unreadable + mismatches + if errors: + for error in errors: + print(f"::error::{error}", file=sys.stderr) + hint = ( + " The plugin's value replaces the curated one once its code loads: declare the same value on both sides." + if mismatches + else "" + ) + print(f"\n{len(errors)} problem(s).{hint}", file=sys.stderr) + return 1 + if compared == 0: + print(f"::error::no driver plugin under {args.apple / 'Plugins'}", file=sys.stderr) + return 1 + print(f"{compared} driver plugins agree with their curated entries on {', '.join(CAPABILITIES)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apple/scripts/ci/test_check_capability_parity.py b/apple/scripts/ci/test_check_capability_parity.py new file mode 100644 index 0000000000..1c6563176d --- /dev/null +++ b/apple/scripts/ci/test_check_capability_parity.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +"""Fixture tests for check-capability-parity.py.""" + +import contextlib +import importlib.util +import io +import tempfile +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location( + "check_capability_parity", Path(__file__).with_name("check-capability-parity.py") +) +check = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check) + +DRIVER_PLUGIN = """public protocol DriverPlugin: TableProPlugin { + static var databaseTypeId: String { get } + static var supportsHealthMonitor: Bool { get } +} + +public extension DriverPlugin { + static var additionalDatabaseTypeIds: [String] { [] } + static var containerEntityName: String { "Database" } + static var supportsHealthMonitor: Bool { true } + static var supportsForeignKeyDisable: Bool { true } + static var supportsSSH: Bool { true } + static var supportsSSL: Bool { true } +} +""" + +PLIST = '\n\n\n\n' + + +def capabilities(ssh="true", ssl="true", fk="true", extra=""): + return f"""PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: {ssh}, + supportsSSL: {ssl}, + supportsCascadeDrop: false, + supportsForeignKeyDisable: {fk},{extra} + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false + )""" + + +def schema(container='"Database"'): + return f"""PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: {container}, + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [.name, .type] + )""" + + +def snapshot(health="true", caps=None, info=None, tail=""): + return f"""PluginMetadataSnapshot( + displayName: "Example", iconName: "cylinder", defaultPort: 1_234, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "example", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: {health}, urlSchemes: ["example"], postConnectActions: [], + brandColorHex: "#123456", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: {caps or capabilities()}, + schema: {info or schema()}, + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, statementCompletions: [], columnTypesByCategory: ["Text": ["TEXT"]] + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + category: .relational, tagline: String(localized: "Example (\\(1 + 1)) \\"engine\\"") + ) + ){tail}""" + + +def registry(*entries, helpers=""): + listed = ",\n".join(f' ("{type_id}", {body})' for type_id, body in entries) + return f"""import Foundation + +extension PluginMetadataRegistry {{ + func examplePluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] {{ + [ +{listed} + ] + }} +{helpers} +}} +""" + + +def plugin_class(name, type_id, *statics, extra=""): + lines = "".join(f" {line}\n" for line in statics) + return f"""import Foundation +import TableProPluginKit + +final class {name}: NSObject, TableProPlugin, DriverPlugin {{ + static let pluginName = "{name}" + static let databaseTypeId = "{type_id}" +{lines} + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {{ + ExampleDriver(config: config) + }} +}} +{extra}""" + + +class CheckCapabilityParityTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.apple = Path(self.temp.name) + kit = self.apple / "Plugins" / "TableProPluginKit" + kit.mkdir(parents=True) + (kit / "DriverPlugin.swift").write_text(DRIVER_PLUGIN, encoding="utf-8") + (self.apple / "TablePro" / "Core" / "Plugins").mkdir(parents=True) + + def tearDown(self): + self.temp.cleanup() + + def curated(self, text, name="PluginMetadataRegistry+ExampleDefaults.swift"): + (self.apple / "TablePro" / "Core" / "Plugins" / name).write_text(text, encoding="utf-8") + + def plugin(self, folder, swift, name="ExamplePlugin.swift"): + directory = self.apple / "Plugins" / folder + directory.mkdir(parents=True, exist_ok=True) + (directory / "Info.plist").write_text(PLIST, encoding="utf-8") + (directory / name).write_text(swift, encoding="utf-8") + return directory + + def problems(self): + unreadable, mismatches, _ = check.problems_in(self.apple) + return unreadable, mismatches + + def run_main(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = check.main(["--apple", str(self.apple)]) + return code, out.getvalue(), err.getvalue() + + def test_a_plugin_that_keeps_the_defaults_the_entry_states_passes(self): + self.curated(registry(("Example", snapshot()))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + code, out, err = self.run_main() + self.assertEqual(code, 0, err) + self.assertIn("1 driver plugins agree", out) + + def test_a_curated_health_monitor_opt_out_the_plugin_does_not_repeat_fails(self): + # #2700: the plugin's default `true` replaced the curated opt-out and the app pinged the connection. + self.curated(registry(("SQLite", snapshot(health="false")))) + self.plugin("SQLiteDriverPlugin", plugin_class("SQLitePlugin", "SQLite")) + unreadable, mismatches = self.problems() + self.assertEqual(unreadable, []) + self.assertEqual(len(mismatches), 1) + self.assertIn("SQLite supportsHealthMonitor: curated false", mismatches[0]) + self.assertIn("plugin true (PluginKit default, Plugins/TableProPluginKit/DriverPlugin.swift:9)", mismatches[0]) + + self.plugin("SQLiteDriverPlugin", plugin_class("SQLitePlugin", "SQLite", "static let supportsHealthMonitor = false")) + self.assertEqual(self.problems(), ([], [])) + + def test_a_plugin_opt_out_the_curated_entry_does_not_repeat_fails(self): + self.curated(registry(("Example", snapshot()))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example", "static let supportsHealthMonitor = false")) + _, mismatches = self.problems() + self.assertEqual(len(mismatches), 1) + self.assertIn("curated true", mismatches[0]) + self.assertIn("plugin false (Plugins/ExampleDriverPlugin/ExamplePlugin.swift:7)", mismatches[0]) + + def test_a_duckdb_style_transport_flip_fails_until_both_sides_agree(self): + self.curated(registry(("DuckDB", snapshot(caps=capabilities(ssh="false", ssl="false"))))) + self.plugin("DuckDBDriverPlugin", plugin_class("DuckDBPlugin", "DuckDB")) + _, mismatches = self.problems() + self.assertEqual( + [line.split(":")[0] for line in mismatches], ["DuckDB supportsSSH", "DuckDB supportsSSL"] + ) + + self.curated(registry(("DuckDB", snapshot(caps=capabilities(ssh="true", ssl="false"))))) + self.plugin( + "DuckDBDriverPlugin", + plugin_class("DuckDBPlugin", "DuckDB", "static let supportsSSH = true", "static let supportsSSL: Bool = false"), + ) + self.assertEqual(self.problems(), ([], [])) + + def test_a_container_name_and_a_foreign_key_flag_the_plugin_does_not_restate_fail(self): + self.curated(registry(("Beancount", snapshot(caps=capabilities(fk="false"), info=schema('"Ledger"'))))) + self.plugin("BeancountDriverPlugin", plugin_class("BeancountPlugin", "Beancount")) + _, mismatches = self.problems() + self.assertEqual(len(mismatches), 2) + self.assertIn('Beancount containerEntityName: curated "Ledger"', mismatches[1]) + self.assertIn('plugin "Database"', mismatches[1]) + + statics = ("static let supportsForeignKeyDisable = false", 'static var containerEntityName: String { "Ledger" }') + self.plugin("BeancountDriverPlugin", plugin_class("BeancountPlugin", "Beancount", *statics)) + self.assertEqual(self.problems(), ([], [])) + + def test_references_to_literals_are_followed_on_both_sides(self): + helpers = """ + static let exampleSchemaInfo = PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", defaultGroupName: "main", tableEntityName: "Tables", containerEntityName: "Schema", + defaultPrimaryKeyColumn: nil, immutableColumns: [], systemDatabaseNames: [], systemSchemaNames: [], + fileExtensions: [], databaseGroupingStrategy: .bySchema, structureColumnFields: [.name] + ) + + private func exampleCapabilities(lite: Bool) -> PluginMetadataSnapshot.CapabilityFlags { + """ + capabilities(ssh="false", extra="\n exactRowCountIsFullScan: lite,") + """ + } +""" + self.curated(registry( + ("Example", snapshot(caps="exampleCapabilities(lite: false)", info="Self.exampleSchemaInfo")), + helpers=helpers, + )) + metadata = 'enum ExampleMetadata {\n static let containerEntityName = "Schema"\n}\n' + self.plugin( + "ExampleDriverPlugin", + plugin_class( + "ExamplePlugin", "Example", + "static let containerEntityName = ExampleMetadata.containerEntityName", + "static let supportsSSH = Self.remoteTransport", + "static let remoteTransport = false", + ), + ) + (self.apple / "Plugins" / "ExampleDriverPlugin" / "ExampleMetadata.swift").write_text(metadata, encoding="utf-8") + self.assertEqual(self.problems(), ([], [])) + + def test_a_snapshot_parameter_named_after_a_type_reads_that_entry_and_test_doubles_do_not_count(self): + fork = """PluginMetadataSnapshot( + displayName: "Fork", iconName: "cylinder", defaultPort: 1, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "fork", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: example.supportsHealthMonitor, urlSchemes: ["fork"], postConnectActions: [], + brandColorHex: "#000000", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: example.capabilities, schema: example.schema, editor: example.editor, + connection: example.connection + ).withSystemNames(databases: [], schemas: ["sys"])""" + helpers = f""" + static func forkDefaults(example: PluginMetadataSnapshot) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] {{ + [("Fork", {fork})] + }} +""" + self.curated(registry(("Example", snapshot(caps=capabilities(fk="false"))), helpers=helpers)) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example", "static let supportsForeignKeyDisable = false")) + self.plugin( + "ForkDriverPlugin", + plugin_class("ForkPlugin", "Fork", "static let supportsForeignKeyDisable = ExamplePlugin.supportsForeignKeyDisable"), + name="ForkPlugin.swift", + ) + # A test bundle's double of the same name must not make the reference ambiguous. + tests = self.apple / "Plugins" / "ExampleDriverTests" + tests.mkdir() + doubled = plugin_class("ExamplePlugin", "Example", "static let supportsForeignKeyDisable = true") + (tests / "Doubles.swift").write_text(doubled, encoding="utf-8") + self.assertEqual(self.problems(), ([], [])) + + self.plugin("ForkDriverPlugin", plugin_class("ForkPlugin", "Fork"), name="ForkPlugin.swift") + _, mismatches = self.problems() + self.assertEqual([line.split(":")[0] for line in mismatches], ["Fork supportsForeignKeyDisable"]) + + def test_a_reference_to_an_undeclared_static_falls_back_to_the_default_only_on_a_driver(self): + self.curated(registry(("Example", snapshot()), ("Fork", snapshot()))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + self.plugin( + "ForkDriverPlugin", + plugin_class( + "ForkPlugin", "Fork", + "static let supportsSSH = ExamplePlugin.supportsSSH", + "static let supportsSSL = ForkMetadata.supportsSSL", + extra="enum ForkMetadata {}\n", + ), + name="ForkPlugin.swift", + ) + unreadable, mismatches = self.problems() + self.assertEqual(mismatches, []) + self.assertEqual(len(unreadable), 1) + self.assertIn("Fork supportsSSL", unreadable[0]) + self.assertIn("ForkMetadata declares no static supportsSSL", unreadable[0]) + + def test_an_expression_that_is_not_a_literal_is_a_failure_not_a_skip(self): + self.curated(registry(("Example", snapshot(caps=capabilities(ssh="!isEmbedded"))))) + statics = ("static let supportsSSL = isRemote && true", 'static let containerEntityName = "\\(prefix)base"') + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example", *statics)) + unreadable, mismatches = self.problems() + self.assertEqual(mismatches, []) + self.assertEqual( + [line.split(":")[0] for line in unreadable], + ["Example supportsSSH", "Example supportsSSL", "Example containerEntityName"], + ) + self.assertTrue(all("as a literal" in line for line in unreadable), unreadable) + + code, _, err = self.run_main() + self.assertEqual(code, 1) + self.assertIn("::error::Example supportsSSH", err) + self.assertNotIn("declare the same value", err) + + def test_an_argument_the_entry_does_not_pass_is_a_failure(self): + caps = capabilities().replace(" supportsSSL: true,\n", "") + self.curated(registry(("Example", snapshot(caps=caps)))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + unreadable, _ = self.problems() + self.assertEqual(len(unreadable), 1) + self.assertIn("CapabilityFlags(...) passes no supportsSSL", unreadable[0]) + + def test_a_plugin_with_no_curated_entry_or_two_is_a_failure(self): + self.curated(registry(("Other", snapshot()))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + unreadable, _ = self.problems() + self.assertEqual(unreadable, ['ExampleDriverPlugin: 0 curated entries for "Example" in ' + check.REGISTRY_GLOB]) + + self.curated(registry(("Example", snapshot()), ("Example", snapshot()))) + unreadable, _ = self.problems() + self.assertEqual(len(unreadable), 1) + self.assertIn('2 curated entries for "Example"', unreadable[0]) + + def test_a_static_declared_twice_is_a_failure(self): + self.curated(registry(("Example", snapshot()))) + self.plugin( + "ExampleDriverPlugin", + plugin_class("ExamplePlugin", "Example", "static let supportsSSH = true", + extra="extension ExamplePlugin {\n static let supportsSSH = false\n}\n"), + ) + unreadable, _ = self.problems() + self.assertEqual(len(unreadable), 1) + self.assertIn("ExamplePlugin declares supportsSSH 2 times", unreadable[0]) + + def test_a_conformance_declared_in_an_extension_counts(self): + self.curated(registry(("Example", snapshot(health="false")))) + swift = plugin_class("ExamplePlugin", "Example").replace(", DriverPlugin {", " {") + swift += "\nextension ExamplePlugin: DriverPlugin {\n static let supportsHealthMonitor = false\n}\n" + self.plugin("ExampleDriverPlugin", swift) + code, out, err = self.run_main() + self.assertEqual(code, 0, err) + self.assertIn("1 driver plugins agree", out) + + def test_a_static_in_an_extension_counts(self): + self.curated(registry(("Example", snapshot(health="false")))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + (self.apple / "Plugins" / "ExampleDriverPlugin" / "ExamplePlugin+Health.swift").write_text( + "extension ExamplePlugin {\n static var supportsHealthMonitor: Bool {\n return false\n }\n}\n", + encoding="utf-8", + ) + self.assertEqual(self.problems(), ([], [])) + + def test_comments_strings_and_nested_types_do_not_count(self): + self.curated(registry(("Example", snapshot( + caps=capabilities(ssh="false /* supportsSSL: false, */"), + info=schema('"Data\\"base\\" (main)"'), + )))) + swift = plugin_class( + "ExamplePlugin", "Example", + "// static let supportsSSL = false", + 'static let note = "static let supportsHealthMonitor = false"', + 'static let raw = #"static let supportsForeignKeyDisable = "no""#', + "static let supportsSSH = false // true until Remote mode dialed a server", + 'static let containerEntityName = "Data\\"base\\" (main)"', + "enum Inner {\n static let supportsSSH = true\n }", + ) + self.plugin("ExampleDriverPlugin", swift) + self.assertEqual(self.problems(), ([], [])) + + def test_a_modifier_that_may_change_a_compared_capability_is_a_failure(self): + safe = snapshot(tail='.withSystemNames(databases: ["sys"], schemas: [])\n .withIconName("icon")') + self.curated(registry(("Example", safe))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + self.assertEqual(self.problems(), ([], [])) + + self.curated(registry(("Example", snapshot(tail=".withSwitchRouting(from: other)")))) + unreadable, _ = self.problems() + self.assertEqual(len(unreadable), 5) + self.assertIn("withSwitchRouting(from: other)` may change a capability", unreadable[0]) + + def test_a_variant_keeps_its_curated_entry_and_is_not_compared(self): + self.curated(registry(("Example", snapshot()), ("ExampleLite", snapshot(health="false")))) + self.plugin( + "ExampleDriverPlugin", + plugin_class("ExamplePlugin", "Example", 'static let additionalDatabaseTypeIds = ["ExampleLite"]'), + ) + self.assertEqual(self.problems(), ([], [])) + + def test_a_call_that_is_not_an_entry_is_not_read_as_one(self): + helpers = """ + static let templates = [requestTemplate("Example", #"{"TableName": ""}"#)] +""" + self.curated(registry(("Example", snapshot()), helpers=helpers)) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + self.assertEqual(self.problems(), ([], [])) + + def test_folders_without_a_driver_plugin_are_not_compared(self): + self.curated(registry(("Example", snapshot()))) + self.plugin("ExampleDriverPlugin", plugin_class("ExamplePlugin", "Example")) + self.plugin("CSVExportPlugin", 'final class CSVExportPlugin: ExportFormatPlugin {\n static let formatId = "csv"\n}\n') + code, out, _ = self.run_main() + self.assertEqual(code, 0) + self.assertIn("1 driver plugins agree", out) + + def test_an_empty_plugins_folder_fails(self): + self.curated(registry(("Example", snapshot()))) + code, _, err = self.run_main() + self.assertEqual(code, 1) + self.assertIn("no driver plugin", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/apple/scripts/probes/check-quack-tls.sh b/apple/scripts/probes/check-quack-tls.sh new file mode 100755 index 0000000000..0763b209fe --- /dev/null +++ b/apple/scripts/probes/check-quack-tls.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# +# Pins the Quack facts the DuckDB driver hard-codes for a Remote connection, against the DuckDB CLI at the +# DUCKDB_VERSION scripts/build-duckdb.sh ships: which ATTACH option or httpfs setting trusts a server behind a private +# CA, the error text each wrong pairing gives (the driver maps it to a hint), what changes when a tunnel's loopback +# forward carries the connection, which secret supplies the token, and that SET GLOBAL ca_cert_file replaces the +# public CAs. Run it after bumping DUCKDB_VERSION or when a Quack connect starts failing in a new way. +# +# Hand-run, never CI: it downloads the CLI and the quack and httpfs extensions, listens on this Mac's LAN address and +# reads one public HTTPS URL. Every DuckDB it starts gets its own HOME, so nothing touches ~/.duckdb. +# +# Usage: scripts/probes/check-quack-tls.sh [path/to/duckdb] +# +# Needs gh (unless given a CLI), python3 and nc. Exits 1 when a case fails, 3 when it cannot run. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VERSION="$(sed -nE 's/^DUCKDB_VERSION="(.*)"/\1/p' "$ROOT/scripts/build-duckdb.sh")" +OPENSSL=/usr/bin/openssl +TOKEN="probe-token" +PUBLIC_URL="https://duckdb.org/robots.txt" +WORK="$(mktemp -d)" + +# The servers, their stdin holders and the forwards are all children of this shell. +cleanup() { + pkill -P $$ + wait + rm -rf "$WORK" +} 2> /dev/null +trap cleanup EXIT + +cannot_run() { + echo "cannot run: $*" >&2 + exit 3 +} + +CLI="${1:-}" +if [ -z "$CLI" ]; then + mkdir -p "$WORK/cli" + gh release download "$VERSION" --repo duckdb/duckdb --pattern duckdb-cli-osx-universal.tar.gz --dir "$WORK/cli" \ + || cannot_run "gh release download $VERSION failed" + tar -xzf "$WORK/cli/duckdb-cli-osx-universal.tar.gz" -C "$WORK/cli" || cannot_run "the CLI archive did not unpack" + CLI="$WORK/cli/duckdb" +fi +"$CLI" -version 2> /dev/null | grep -q "^$VERSION " || cannot_run "$CLI is not DuckDB $VERSION" + +# The CLI renders for an agent when one's environment is set; the cases read its plain output. +CLI_ARGS=(-no-agent -no-init -batch -list -noheader) + +SEED="$WORK/seed" +mkdir -p "$SEED" +HOME="$SEED" "$CLI" "${CLI_ARGS[@]}" -c "INSTALL quack; INSTALL httpfs;" > /dev/null || cannot_run "INSTALL quack, httpfs failed" +EXTENSIONS="$(HOME="$SEED" "$CLI" "${CLI_ARGS[@]}" -c "SELECT string_agg(extension_name || ' ' || extension_version, ', ') + FROM duckdb_extensions() WHERE extension_name IN ('quack', 'httpfs')")" + +# A fresh HOME holds no stored secret and no setting; only the downloaded extensions are shared. +new_home() { + local home + home="$(mktemp -d "$WORK/home.XXXXXX")" + mkdir -p "$home/.duckdb" + ln -s "$SEED/.duckdb/extensions" "$home/.duckdb/extensions" + echo "$home" +} + +LAN_IP="" +for interface in en0 en1 en2 en3 en4 en5; do + LAN_IP="$(ipconfig getifaddr "$interface")" && break +done +[ -n "$LAN_IP" ] || cannot_run "no LAN address from ipconfig getifaddr" + +# The server certificate names only the LAN address, as a private CA issues it, so a loopback dial fails the name. +CERTS="$WORK/certs" +mkdir -p "$CERTS" +{ + "$OPENSSL" req -x509 -newkey rsa:2048 -nodes -days 2 -subj "/CN=TablePro probe CA" \ + -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign" \ + -keyout "$CERTS/ca.key" -out "$CERTS/ca.pem" && + "$OPENSSL" req -newkey rsa:2048 -nodes -subj "/CN=$LAN_IP" -keyout "$CERTS/server.key" -out "$CERTS/server.csr" && + printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n' "$LAN_IP" > "$CERTS/server.ext" && + "$OPENSSL" x509 -req -days 2 -set_serial 1 -in "$CERTS/server.csr" -CA "$CERTS/ca.pem" -CAkey "$CERTS/ca.key" \ + -extfile "$CERTS/server.ext" -out "$CERTS/server.pem" +} > /dev/null 2>&1 || cannot_run "openssl could not make the certificates" +PIN="$("$OPENSSL" x509 -noout -fingerprint -sha256 -in "$CERTS/server.pem" | cut -d= -f2)" +case "$PIN" in + 0*) WRONG_PIN="1${PIN#?}" ;; + *) WRONG_PIN="0${PIN#?}" ;; +esac +cat /etc/ssl/cert.pem "$CERTS/ca.pem" > "$CERTS/bundle.pem" + +# All four sockets are held at once so no two ports come back the same. +read -r TLS_PORT PLAIN_PORT TLS_FORWARD PLAIN_FORWARD < <(python3 -I -c ' +import socket +held = [socket.socket() for _ in range(4)] +for s in held: + s.bind(("127.0.0.1", 0)) +print(*(s.getsockname()[1] for s in held))') + +# A server serves for as long as its CLI reads stdin, so a sleep holds stdin open until cleanup. +start_server() { # log name, listen URI, the rest of quack_serve's arguments + local home + home="$(new_home)" + { + echo "LOAD quack; CREATE TABLE t AS SELECT 42 AS answer;" + echo "FROM quack_serve('$2', token = '$TOKEN'$3);" + exec sleep 900 + } | HOME="$home" "$CLI" "${CLI_ARGS[@]}" > "$WORK/$1.log" 2>&1 & +} + +# Stands in for an SSH, Cloudflare or SOCKS tunnel: the client dials a loopback port and the bytes reach the server. +FORWARD=' +import socket, sys, threading +def pipe(source, sink): + try: + while data := source.recv(65536): + sink.sendall(data) + except OSError: + pass + for s in (source, sink): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass +listener = socket.create_server(("127.0.0.1", int(sys.argv[1]))) +while True: + client, _ = listener.accept() + server = socket.create_connection((sys.argv[2], int(sys.argv[3]))) + threading.Thread(target=pipe, args=(client, server), daemon=True).start() + threading.Thread(target=pipe, args=(server, client), daemon=True).start() +' + +wait_for() { # host, port, log to show when it never listens + local tries=0 + until nc -z -G 1 "$1" "$2" 2> /dev/null; do + tries=$((tries + 1)) + if [ "$tries" -ge 50 ]; then + [ -f "$3" ] && cat "$3" >&2 + cannot_run "nothing listens on $1:$2" + fi + sleep 0.2 + done +} + +start_server tls "quack:$LAN_IP:$TLS_PORT" \ + ", allow_other_hostname = true, ssl_cert_file = '$CERTS/server.pem', ssl_key_file = '$CERTS/server.key'" +start_server plain "quack:127.0.0.1:$PLAIN_PORT" "" +python3 -I -c "$FORWARD" "$TLS_FORWARD" "$LAN_IP" "$TLS_PORT" & +python3 -I -c "$FORWARD" "$PLAIN_FORWARD" 127.0.0.1 "$PLAIN_PORT" & +wait_for "$LAN_IP" "$TLS_PORT" "$WORK/tls.log" +wait_for 127.0.0.1 "$PLAIN_PORT" "$WORK/plain.log" +wait_for 127.0.0.1 "$TLS_FORWARD" "" +wait_for 127.0.0.1 "$PLAIN_FORWARD" "" + +TLS="quack:$LAN_IP:$TLS_PORT" +PLAIN="quack:127.0.0.1:$PLAIN_PORT" +TLS_VIA_FORWARD="quack:127.0.0.1:$TLS_FORWARD" +PLAIN_VIA_FORWARD="quack:127.0.0.1:$PLAIN_FORWARD" +echo "DuckDB $VERSION ($EXTENSIONS); TLS server $TLS, pin $PIN; plain server $PLAIN" +echo + +# The driver's own shape: the token in a named secret ATTACH names, dropped once ATTACH holds it, then a read. +attach() { # URI, ATTACH options after SECRET + echo "CREATE TEMPORARY SECRET tablepro_quack (TYPE quack, TOKEN '$TOKEN');" + echo "ATTACH '$1' AS r (SECRET tablepro_quack${2:+, $2});" + echo "DROP TEMPORARY SECRET tablepro_quack;" + echo "SELECT 'remote answer ' || CAST(answer AS VARCHAR) FROM r.t;" +} +SERVED="remote answer 42" +PUBLIC_READ="SELECT 'public read ' || CAST(length(content) > 0 AS VARCHAR) FROM read_text('$PUBLIC_URL');" +PUBLIC_READ_OK="public read true" + +FAILURES=0 +check() { # name, text the output must hold, then the statements to run after LOAD quack + local name="$1" expected="$2" home output + shift 2 + home="$(new_home)" + output="$({ echo "LOAD quack;" && printf '%s\n' "$@"; } | + HOME="$home" /usr/bin/perl -e 'alarm shift; exec @ARGV' 60 "$CLI" "${CLI_ARGS[@]}" -bail 2>&1)" + if printf '%s' "$output" | grep -qF -- "$expected"; then + echo "PASS $name" + else + FAILURES=$((FAILURES + 1)) + echo "FAIL $name" + echo " expected: $expected" + echo " got: $(printf '%s' "$output" | grep -v '^[[:space:]]*$' | tail -n 1)" + fi +} + +# A secret the user stored earlier, from another process, and then a session opened as the driver opens Remote: +# persistent secrets off, so nothing in ~/.duckdb reaches the token or the trust of the connection. +check_stored() { # name, expected text, statements storing a secret, then the session's statements + local name="$1" expected="$2" stored="$3" home output + shift 3 + home="$(new_home)" + printf '%s\n' "LOAD quack;" "$stored" | HOME="$home" "$CLI" "${CLI_ARGS[@]}" >/dev/null 2>&1 + output="$({ echo "SET allow_persistent_secrets = false;" && echo "LOAD quack;" && printf '%s\n' "$@"; } | + HOME="$home" /usr/bin/perl -e 'alarm shift; exec @ARGV' 60 "$CLI" "${CLI_ARGS[@]}" -bail 2>&1)" + if printf '%s' "$output" | grep -qF -- "$expected"; then + echo "PASS $name" + else + FAILURES=$((FAILURES + 1)) + echo "FAIL $name" + echo " expected: $expected" + echo " got: $(printf '%s' "$output" | grep -v '^[[:space:]]*$' | tail -n 1)" + fi +} + +SKIP_VERIFY="SET GLOBAL enable_curl_server_cert_verification = false;" +CA_ONLY="SET GLOBAL ca_cert_file = '$CERTS/ca.pem';" +UNTRUSTED="SSL peer certificate or SSH remote key was not OK" + +check "direct, Automatic: the private CA is not trusted" "$UNTRUSTED" "$(attach "$TLS")" +check "direct, ca_cert_file = the CA" "$SERVED" "LOAD httpfs;" "$CA_ONLY" "$(attach "$TLS" "DISABLE_SSL false")" +check "direct, ca_cert_file = system bundle plus the CA: server and public HTTPS" "$PUBLIC_READ_OK" \ + "LOAD httpfs;" "SET GLOBAL ca_cert_file = '$CERTS/bundle.pem';" "$(attach "$TLS" "DISABLE_SSL false")" \ + "$PUBLIC_READ" +check "direct, enable_curl_server_cert_verification = false" "$SERVED" \ + "LOAD httpfs;" "$SKIP_VERIFY" "$(attach "$TLS" "DISABLE_SSL false")" +check "direct, SSL_FINGERPRINT" "$SERVED" "$(attach "$TLS" "SSL_FINGERPRINT '$PIN'")" +check "direct, wrong SSL_FINGERPRINT" "does not match the pinned ssl_fingerprint" \ + "$(attach "$TLS" "SSL_FINGERPRINT '$WRONG_PIN'")" +check "direct, DISABLE_SSL true to a TLS server" "Unsupported protocol" "$(attach "$TLS" "DISABLE_SSL true")" +check "direct, DISABLE_SSL false to a plain server" "SSL connect error" "$(attach "$PLAIN" "DISABLE_SSL false")" + +check "forward, plain server, no DISABLE_SSL" "$SERVED" "$(attach "$PLAIN_VIA_FORWARD")" +check "forward, TLS server, SSL_FINGERPRINT" "$SERVED" "$(attach "$TLS_VIA_FORWARD" "SSL_FINGERPRINT '$PIN'")" +check "forward, TLS server, skip verify" "$SERVED" \ + "LOAD httpfs;" "$SKIP_VERIFY" "$(attach "$TLS_VIA_FORWARD" "DISABLE_SSL false")" +check "forward, TLS server, ca_cert_file: the name is 127.0.0.1" "$UNTRUSTED" \ + "LOAD httpfs;" "$CA_ONLY" "$(attach "$TLS_VIA_FORWARD" "DISABLE_SSL false")" +check "forward, TLS server, no DISABLE_SSL: loopback means plain HTTP" "Unsupported protocol" \ + "$(attach "$TLS_VIA_FORWARD")" + +# A user's own persistent secret scoped to the server, holding a stale token. +USER_SECRET="CREATE PERSISTENT SECRET user_lan (TYPE quack, TOKEN 'stale-token', SCOPE '$TLS');" +check_stored "secret: a stored secret with a stale token does not reach the session" "$SERVED" \ + "$USER_SECRET" "$(attach "$TLS" "SSL_FINGERPRINT '$PIN'")" +check "secret: an unnamed secret and a bare ATTACH lose to the scoped one" "Authentication failed" \ + "$USER_SECRET" "CREATE OR REPLACE SECRET (TYPE quack, TOKEN '$TOKEN');" \ + "ATTACH '$TLS' AS r (SSL_FINGERPRINT '$PIN');" "SELECT answer FROM r.t;" + +# A pin the user stored for the server is not inherited by a mode that checks the CA and name, and its token does not +# fill an empty Token: with persistent secrets off, the driver's own secret is the only one ATTACH can find. +USER_PIN="CREATE PERSISTENT SECRET user_pin (TYPE quack, TOKEN '$TOKEN', SSL_FINGERPRINT '$PIN', SCOPE '$TLS');" +check_stored "secret: a stored pin does not stand in for Verify Identity" "$UNTRUSTED" \ + "$USER_PIN" "$(attach "$TLS" "DISABLE_SSL false")" +check_stored "secret: a stored pin does not stand in for Automatic" "$UNTRUSTED" \ + "$USER_PIN" "$(attach "$TLS")" +check_stored "secret: a stored token does not stand in for an empty Token" "Could not find a Quack authentication token" \ + "$USER_PIN" "ATTACH '$TLS' AS r;" + +check "secret: control, with persistent secrets on the stored pin stands in" "$SERVED" \ + "$USER_PIN" "CREATE TEMPORARY SECRET tablepro_quack (TYPE quack, TOKEN '$TOKEN');" \ + "ATTACH '$TLS' AS r (SECRET tablepro_quack, DISABLE_SSL false);" "SELECT 'remote answer ' || CAST(answer AS VARCHAR) FROM r.t;" + +# Dropped after ATTACH, the token never reaches a host a later ATTACH names: a scope would not stop it, +# since DuckDB matches scopes as prefixes. +check "secret: a later ATTACH to another host gets no token" "Could not find a Quack authentication token" \ + "$(attach "$PLAIN")" "ATTACH '$PLAIN_VIA_FORWARD' AS other;" + +check "public HTTPS with the default bundle" "$PUBLIC_READ_OK" "LOAD httpfs;" "$PUBLIC_READ" +check "public HTTPS after SET GLOBAL ca_cert_file = the CA" "$UNTRUSTED" "LOAD httpfs;" "$CA_ONLY" "$PUBLIC_READ" + +echo +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES case(s) do not hold" + exit 1 +fi +echo "every case holds" From b0a489c2bb46ebfa275a90265290944629e36188 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 00:13:17 +0700 Subject: [PATCH 2/2] docs(changelog): fragments for #4021 --- apple/changelog.d/4021.added.md | 1 + apple/changelog.d/4021.changed.md | 1 + apple/changelog.d/4021.fixed.1.md | 1 + apple/changelog.d/4021.fixed.2.md | 1 + apple/changelog.d/4021.fixed.3.md | 1 + apple/changelog.d/4021.fixed.4.md | 1 + apple/changelog.d/4021.fixed.md | 1 + apple/changelog.d/4021.security.md | 1 + 8 files changed, 8 insertions(+) create mode 100644 apple/changelog.d/4021.added.md create mode 100644 apple/changelog.d/4021.changed.md create mode 100644 apple/changelog.d/4021.fixed.1.md create mode 100644 apple/changelog.d/4021.fixed.2.md create mode 100644 apple/changelog.d/4021.fixed.3.md create mode 100644 apple/changelog.d/4021.fixed.4.md create mode 100644 apple/changelog.d/4021.fixed.md create mode 100644 apple/changelog.d/4021.security.md diff --git a/apple/changelog.d/4021.added.md b/apple/changelog.d/4021.added.md new file mode 100644 index 0000000000..627ece0c72 --- /dev/null +++ b/apple/changelog.d/4021.added.md @@ -0,0 +1 @@ +Remote DuckDB connections can go through an SSH tunnel, Cloudflare Tunnel, SOCKS proxy or tunnel command. diff --git a/apple/changelog.d/4021.changed.md b/apple/changelog.d/4021.changed.md new file mode 100644 index 0000000000..6745afeeb8 --- /dev/null +++ b/apple/changelog.d/4021.changed.md @@ -0,0 +1 @@ +Remote DuckDB connections now require the Token and no longer read secrets saved in ~/.duckdb. diff --git a/apple/changelog.d/4021.fixed.1.md b/apple/changelog.d/4021.fixed.1.md new file mode 100644 index 0000000000..81a931f7a2 --- /dev/null +++ b/apple/changelog.d/4021.fixed.1.md @@ -0,0 +1 @@ +Drop offered to ignore foreign key checks on DuckDB, ClickHouse, SQL Server, SurrealDB, Teradata and Beancount. diff --git a/apple/changelog.d/4021.fixed.2.md b/apple/changelog.d/4021.fixed.2.md new file mode 100644 index 0000000000..715aa7867e --- /dev/null +++ b/apple/changelog.d/4021.fixed.2.md @@ -0,0 +1 @@ +Beancount and Memcached showed Database instead of Ledger or Server once their driver loaded. diff --git a/apple/changelog.d/4021.fixed.3.md b/apple/changelog.d/4021.fixed.3.md new file mode 100644 index 0000000000..40c9e7f27b --- /dev/null +++ b/apple/changelog.d/4021.fixed.3.md @@ -0,0 +1 @@ +A copied connection URL misread IPv6 hosts and dropped the settings after a database name holding # or ?. diff --git a/apple/changelog.d/4021.fixed.4.md b/apple/changelog.d/4021.fixed.4.md new file mode 100644 index 0000000000..19a8eff009 --- /dev/null +++ b/apple/changelog.d/4021.fixed.4.md @@ -0,0 +1 @@ +A remote DuckDB connection that could not download the Quack extension showed a misleading error. diff --git a/apple/changelog.d/4021.fixed.md b/apple/changelog.d/4021.fixed.md new file mode 100644 index 0000000000..49170c64ed --- /dev/null +++ b/apple/changelog.d/4021.fixed.md @@ -0,0 +1 @@ +Remote DuckDB connections ignored the SSL settings shown and could not trust a private CA or self-signed certificate. diff --git a/apple/changelog.d/4021.security.md b/apple/changelog.d/4021.security.md new file mode 100644 index 0000000000..ca2c7f4fc2 --- /dev/null +++ b/apple/changelog.d/4021.security.md @@ -0,0 +1 @@ +A remote DuckDB token could reach another Quack server that a later ATTACH in the same session named.