From 32f6a6c099936b37bf0c02585587f3fc4a938c67 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 21 May 2026 20:08:59 +0700 Subject: [PATCH 1/3] feat(import): import connections from DataGrip --- .../DataGrip/DataGripDataSourceParser.swift | 153 ++++++++ .../DataGrip/JDBCConnectionString.swift | 200 ++++++++++ .../Export/ForeignApp/DataGripImporter.swift | 351 ++++++++++++++++++ .../ForeignApp/ForeignAppImporter.swift | 1 + .../JetBrains/JetBrainsCredentialStore.swift | 185 +++++++++ .../ForeignApp/JetBrains/KdbxDatabase.swift | 349 +++++++++++++++++ .../JetBrains/KdbxInnerStreamCipher.swift | 180 +++++++++ .../ForeignApp/DataGripImporterTests.swift | 240 ++++++++++++ .../JDBCConnectionStringTests.swift | 165 ++++++++ .../JetBrainsCredentialStoreTests.swift | 53 +++ .../ForeignApp/KdbxDatabaseTests.swift | 51 +++ .../KdbxInnerStreamCipherTests.swift | 64 ++++ .../Services/ForeignApp/KdbxTestFixture.swift | 176 +++++++++ docs/features/connection-sharing.mdx | 3 +- 14 files changed, 2170 insertions(+), 1 deletion(-) create mode 100644 TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift create mode 100644 TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift create mode 100644 TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift create mode 100644 TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift create mode 100644 TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift create mode 100644 TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift create mode 100644 TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift create mode 100644 TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift create mode 100644 TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift create mode 100644 TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift create mode 100644 TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift create mode 100644 TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift b/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift new file mode 100644 index 000000000..03741dbef --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift @@ -0,0 +1,153 @@ +// +// DataGripDataSourceParser.swift +// TablePro +// + +import Foundation + +struct DataGripDataSource { + let uuid: String + let name: String + let driverRef: String + let jdbcURL: String + var username: String + let groupName: String? + let ssh: DataGripSSHReference? + let ssl: DataGripSSLProperties? +} + +struct DataGripSSHReference { + let enabled: Bool + let configId: String? + let inlineHost: String? + let inlinePort: Int? + let inlineUser: String? +} + +struct DataGripSSLProperties { + let mode: String? + let caCertPath: String? + let clientCertPath: String? + let clientKeyPath: String? +} + +struct DataGripSSHConfig { + let id: String + let host: String + let port: Int? + let username: String + let authType: String + let keyPath: String? +} + +enum DataGripDataSourceParser { + static func parseDataSources(_ data: Data) -> [DataGripDataSource] { + guard let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//data-source") else { return [] } + + return nodes.compactMap { node in + guard let element = node as? XMLElement else { return nil } + return parseDataSource(element) + } + } + + static func parseSSHConfigs(_ data: Data) -> [String: DataGripSSHConfig] { + guard let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//sshConfig") else { return [:] } + + var result: [String: DataGripSSHConfig] = [:] + for node in nodes { + guard let element = node as? XMLElement, + let id = element.attr("id") else { continue } + let config = DataGripSSHConfig( + id: id, + host: element.attr("host") ?? "", + port: element.attr("port").flatMap { Int($0) }, + username: element.attr("username") ?? "", + authType: element.attr("authType") ?? "PASSWORD", + keyPath: element.attr("keyPath") + ) + result[id] = config + } + return result + } + + /// `dataSources.local.xml` holds the user name and per-user secrets metadata + /// that the shared `dataSources.xml` omits. Returns user names keyed by data-source UUID. + static func parseLocalUserNames(_ data: Data) -> [String: String] { + guard let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//data-source") else { return [:] } + + var result: [String: String] = [:] + for node in nodes { + guard let element = node as? XMLElement, + let uuid = element.attr("uuid"), + let user = element.childText("user-name"), !user.isEmpty else { continue } + result[uuid] = user + } + return result + } + + // MARK: - Private + + private static func parseDataSource(_ element: XMLElement) -> DataGripDataSource? { + guard let uuid = element.attr("uuid"), + let driverRef = element.childText("driver-ref"), + let jdbcURL = element.childText("jdbc-url"), !jdbcURL.isEmpty else { return nil } + + let name = element.attr("name") ?? uuid + let username = element.childText("user-name") ?? "" + let groupName = element.attr("group-name").flatMap { $0.isEmpty ? nil : $0 } + + return DataGripDataSource( + uuid: uuid, + name: name, + driverRef: driverRef, + jdbcURL: jdbcURL, + username: username, + groupName: groupName, + ssh: parseSSHReference(element), + ssl: parseSSLProperties(element) + ) + } + + private static func parseSSHReference(_ element: XMLElement) -> DataGripSSHReference? { + guard let ssh = element.elements(forName: "ssh-properties").first else { return nil } + + let enabled = (ssh.childText("enabled") ?? ssh.attr("enabled")) == "true" + guard enabled else { return nil } + + let configId = ssh.childText("ssh-config-id") ?? ssh.attr("ssh-config-id") + return DataGripSSHReference( + enabled: true, + configId: configId.flatMap { $0.isEmpty ? nil : $0 }, + inlineHost: ssh.attr("host"), + inlinePort: ssh.attr("port").flatMap { Int($0) }, + inlineUser: ssh.attr("user") ?? ssh.attr("username") + ) + } + + private static func parseSSLProperties(_ element: XMLElement) -> DataGripSSLProperties? { + guard let ssl = element.elements(forName: "ssl-properties").first else { return nil } + + let enabled = (ssl.childText("enabled") ?? ssl.attr("enabled")) == "true" + guard enabled else { return nil } + + return DataGripSSLProperties( + mode: ssl.childText("ssl-mode") ?? ssl.childText("mode") ?? ssl.attr("ssl-mode"), + caCertPath: ssl.childText("ca-file") ?? ssl.childText("ca-cert"), + clientCertPath: ssl.childText("client-cert-file") ?? ssl.childText("client-cert"), + clientKeyPath: ssl.childText("client-key-file") ?? ssl.childText("client-key") + ) + } +} + +private extension XMLElement { + func childText(_ name: String) -> String? { + elements(forName: name).first?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func attr(_ name: String) -> String? { + attribute(forName: name)?.stringValue + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift b/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift new file mode 100644 index 000000000..8a0bcc5fe --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift @@ -0,0 +1,200 @@ +// +// JDBCConnectionString.swift +// TablePro +// + +import Foundation + +enum JDBCConnectionString { + struct Endpoint { + let host: String + let port: Int? + let database: String + } + + static func parse(url: String, subprotocol: String) -> Endpoint? { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("jdbc:") else { return nil } + let body = String(trimmed.dropFirst("jdbc:".count)) + + switch subprotocol.lowercased() { + case "sqlserver", "jtds": + return parseSQLServer(body) + case "oracle": + return parseOracle(body) + case "sqlite", "duckdb", "h2": + return parseFilePath(body, subprotocol: subprotocol) + case "bigquery": + return parseBigQuery(body) + default: + return parseAuthority(body) + } + } + + // MARK: - Authority Form + + /// jdbc:://[user[:pass]@]host[:port][/database][?params] + /// Covers MySQL, MariaDB, PostgreSQL, ClickHouse, MongoDB, Cassandra, Redis. + private static func parseAuthority(_ body: String) -> Endpoint? { + guard let schemeRange = body.range(of: "://") else { return nil } + var remainder = String(body[schemeRange.upperBound...]) + + remainder = stripQuery(from: remainder) + let pathSplit = splitOnce(remainder, separator: "/") + let authority = stripUserInfo(pathSplit.head) + let database = pathSplit.tail ?? "" + + let firstHost = authority.components(separatedBy: ",").first ?? authority + let (host, port) = parseHostPort(firstHost) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: database) + } + + // MARK: - SQL Server Form + + /// jdbc:sqlserver://host[\instance][:port][;prop=value;...] + /// jdbc:jtds:sqlserver://host:port/database + private static func parseSQLServer(_ body: String) -> Endpoint? { + var remainder = body + if remainder.lowercased().hasPrefix("jtds:") { + remainder = String(remainder.dropFirst("jtds:".count)) + } + if remainder.lowercased().hasPrefix("sqlserver:") { + remainder = String(remainder.dropFirst("sqlserver:".count)) + } + guard remainder.hasPrefix("//") else { return nil } + remainder = String(remainder.dropFirst(2)) + + let semicolonSplit = splitOnce(remainder, separator: ";") + let properties = parseSemicolonProperties(semicolonSplit.tail ?? "") + + let beforeProps = semicolonSplit.head + let slashSplit = splitOnce(beforeProps, separator: "/") + var authority = slashSplit.head + var database = slashSplit.tail ?? "" + + if let backslash = authority.firstIndex(of: "\\") { + authority = String(authority[.. Endpoint? { + guard let atIndex = body.firstIndex(of: "@") else { return nil } + var descriptor = String(body[body.index(after: atIndex)...]) + descriptor = stripQuery(from: descriptor) + + if descriptor.hasPrefix("//") { + descriptor = String(descriptor.dropFirst(2)) + let slashSplit = splitOnce(descriptor, separator: "/") + let (host, port) = parseHostPort(slashSplit.head) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: slashSplit.tail ?? "") + } + + let parts = descriptor.components(separatedBy: ":") + guard parts.count >= 2, !parts[0].isEmpty else { + let slashSplit = splitOnce(descriptor, separator: "/") + let (host, port) = parseHostPort(slashSplit.head) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: slashSplit.tail ?? "") + } + + let host = parts[0] + let portAndRest = parts[1] + let slashSplit = splitOnce(portAndRest, separator: "/") + let port = Int(slashSplit.head) + + if let serviceName = slashSplit.tail { + return Endpoint(host: host, port: port, database: serviceName) + } + let sid = parts.count >= 3 ? parts[2] : "" + return Endpoint(host: host, port: port, database: sid) + } + + // MARK: - File Form + + /// jdbc:sqlite:/path/to/file.db, jdbc:duckdb:/path/to/file.duckdb + private static func parseFilePath(_ body: String, subprotocol: String) -> Endpoint? { + guard body.lowercased().hasPrefix(subprotocol.lowercased() + ":") else { + return Endpoint(host: "", port: nil, database: stripQuery(from: body)) + } + var path = String(body.dropFirst(subprotocol.count + 1)) + path = stripQuery(from: path) + return Endpoint(host: "", port: nil, database: path) + } + + // MARK: - BigQuery Form + + /// jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;... + private static func parseBigQuery(_ body: String) -> Endpoint? { + guard body.hasPrefix("//") else { return nil } + let remainder = String(body.dropFirst(2)) + let semicolonSplit = splitOnce(remainder, separator: ";") + let properties = parseSemicolonProperties(semicolonSplit.tail ?? "") + let project = properties["projectid"] ?? properties["project"] ?? "" + return Endpoint(host: semicolonSplit.head, port: nil, database: project) + } + + // MARK: - Helpers + + private static func parseHostPort(_ authority: String) -> (host: String, port: Int?) { + if authority.hasPrefix("[") { + guard let closing = authority.firstIndex(of: "]") else { + return (authority, nil) + } + let host = String(authority[authority.index(after: authority.startIndex).. String { + guard let atIndex = authority.lastIndex(of: "@") else { return authority } + return String(authority[authority.index(after: atIndex)...]) + } + + private static func stripQuery(from value: String) -> String { + if let question = value.firstIndex(of: "?") { + return String(value[.. (head: String, tail: String?) { + guard let index = value.firstIndex(of: separator) else { return (value, nil) } + return (String(value[.. [String: String] { + var result: [String: String] = [:] + for pair in value.components(separatedBy: ";") where !pair.isEmpty { + let kv = splitOnce(pair, separator: "=") + guard let rawValue = kv.tail else { continue } + result[kv.head.lowercased().trimmingCharacters(in: .whitespaces)] = rawValue + } + return result + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift new file mode 100644 index 000000000..603d6de53 --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -0,0 +1,351 @@ +// +// DataGripImporter.swift +// TablePro +// + +import AppKit +import Foundation +import os +import TableProPluginKit + +struct DataGripImporter: ForeignAppImporter { + private static let logger = Logger(subsystem: "com.TablePro", category: "DataGripImporter") + + let id = "datagrip" + let displayName = "DataGrip" + let symbolName = "cylinder.split.1x2" + let appBundleIdentifier = "com.jetbrains.datagrip" + + /// Root holding versioned IDE config dirs (`DataGrip2024.3`, ...). Injectable for tests. + var jetBrainsRoot: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/JetBrains") + + private struct Location { + let dataSourcesURL: URL + let localURL: URL? + let sshConfigURL: URL + let configDir: URL + } + + func isAvailable() -> Bool { + installedAppURL() != nil || !locations().isEmpty + } + + func connectionCount() -> Int { + var seen = Set() + for location in locations() { + guard let data = try? Data(contentsOf: location.dataSourcesURL) else { continue } + for source in DataGripDataSourceParser.parseDataSources(data) { + seen.insert(source.uuid) + } + } + return seen.count + } + + func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult { + let locations = locations() + guard !locations.isEmpty else { + throw ForeignAppImportError.fileNotFound(displayName) + } + + var seenUUIDs = Set() + var exportableConnections: [ExportableConnection] = [] + var groupNames = Set() + var credentials: [String: ExportableCredentials] = [:] + var credentialsAborted = false + + for location in locations { + guard let data = try? Data(contentsOf: location.dataSourcesURL) else { continue } + var sources = DataGripDataSourceParser.parseDataSources(data) + mergeLocalUserNames(into: &sources, localURL: location.localURL) + + let sshConfigs = loadSSHConfigs(location) + let credentialStore = includePasswords ? JetBrainsCredentialStore(configDir: location.configDir) : nil + + for source in sources { + guard seenUUIDs.insert(source.uuid).inserted, + let connection = makeConnection(source, sshConfigs: sshConfigs) else { continue } + + let index = exportableConnections.count + exportableConnections.append(connection) + if let groupName = connection.groupName { + groupNames.insert(groupName) + } + + if let store = credentialStore, !credentialsAborted { + switch store.password(forDataSourceUUID: source.uuid) { + case .found(let password): + credentials[String(index)] = ExportableCredentials( + password: password, + sshPassword: nil, + keyPassphrase: nil, + totpSecret: nil, + pluginSecureFields: nil + ) + case .cancelled: + credentialsAborted = true + case .notFound: + break + } + } + } + } + + guard !exportableConnections.isEmpty else { + throw ForeignAppImportError.noConnectionsFound + } + + let groups: [ExportableGroup]? = groupNames.isEmpty ? nil : groupNames.map { + ExportableGroup(name: $0, color: nil) + } + + let envelope = ConnectionExportEnvelope( + formatVersion: 1, + exportedAt: Date(), + appVersion: "DataGrip Import", + connections: exportableConnections, + groups: groups, + tags: nil, + credentials: credentials.isEmpty ? nil : credentials + ) + + return ForeignAppImportResult( + envelope: envelope, + sourceName: displayName, + credentialsAborted: credentialsAborted + ) + } + + // MARK: - Discovery + + private func locations() -> [Location] { + var result: [Location] = [] + for configDir in dataGripConfigDirs() { + appendLocation(directory: configDir.appendingPathComponent("options"), configDir: configDir, into: &result) + + let projectsDir = configDir.appendingPathComponent("projects") + if let projects = try? FileManager.default.contentsOfDirectory( + at: projectsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { + for project in projects { + appendLocation(directory: project.appendingPathComponent(".idea"), configDir: configDir, into: &result) + } + } + + for projectPath in recentProjectPaths(configDir: configDir) { + let ideaDir = URL(fileURLWithPath: projectPath).appendingPathComponent(".idea") + appendLocation(directory: ideaDir, configDir: configDir, into: &result) + } + } + return result + } + + private func dataGripConfigDirs() -> [URL] { + guard let dirs = try? FileManager.default.contentsOfDirectory( + at: jetBrainsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + return dirs + .filter { $0.lastPathComponent.hasPrefix("DataGrip") } + .sorted { $0.lastPathComponent > $1.lastPathComponent } + } + + private func appendLocation(directory: URL, configDir: URL, into result: inout [Location]) { + let dataSources = directory.appendingPathComponent("dataSources.xml") + guard FileManager.default.fileExists(atPath: dataSources.path) else { return } + + let local = directory.appendingPathComponent("dataSources.local.xml") + result.append(Location( + dataSourcesURL: dataSources, + localURL: FileManager.default.fileExists(atPath: local.path) ? local : nil, + sshConfigURL: directory.appendingPathComponent("ssh-config.xml"), + configDir: configDir + )) + } + + private func recentProjectPaths(configDir: URL) -> [String] { + let url = configDir.appendingPathComponent("options/recentProjects.xml") + guard let data = try? Data(contentsOf: url), + let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//entry/@key") else { return [] } + + return nodes.compactMap { node in + node.stringValue.map { $0.replacingOccurrences(of: "$USER_HOME$", with: NSHomeDirectory()) } + } + } + + private func loadSSHConfigs(_ location: Location) -> [String: DataGripSSHConfig] { + var merged: [String: DataGripSSHConfig] = [:] + let urls = [ + location.configDir.appendingPathComponent("options/ssh-config.xml"), + location.sshConfigURL + ] + for url in urls { + guard let data = try? Data(contentsOf: url) else { continue } + merged.merge(DataGripDataSourceParser.parseSSHConfigs(data)) { _, new in new } + } + return merged + } + + private func mergeLocalUserNames(into sources: inout [DataGripDataSource], localURL: URL?) { + guard let localURL, let data = try? Data(contentsOf: localURL) else { return } + let userNames = DataGripDataSourceParser.parseLocalUserNames(data) + for index in sources.indices where sources[index].username.isEmpty { + if let user = userNames[sources[index].uuid] { + sources[index].username = user + } + } + } + + // MARK: - Mapping + + private func makeConnection( + _ source: DataGripDataSource, + sshConfigs: [String: DataGripSSHConfig] + ) -> ExportableConnection? { + let subprotocol = jdbcSubprotocol(source.jdbcURL) + let type = mapDriverRef(source.driverRef, subprotocol: subprotocol) + let endpoint = JDBCConnectionString.parse(url: source.jdbcURL, subprotocol: subprotocol) + + let host = endpoint?.host ?? "localhost" + let database = endpoint?.database ?? "" + let port = endpoint?.port ?? defaultPort(for: type) + + return ExportableConnection( + name: source.name, + host: host, + port: port, + database: database, + username: source.username, + type: type, + sshConfig: makeSSHConfig(source.ssh, sshConfigs: sshConfigs), + sslConfig: makeSSLConfig(source.ssl), + color: nil, + tagName: nil, + groupName: source.groupName, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + } + + private func makeSSHConfig( + _ reference: DataGripSSHReference?, + sshConfigs: [String: DataGripSSHConfig] + ) -> ExportableSSHConfig? { + guard let reference, reference.enabled else { return nil } + + let config = reference.configId.flatMap { sshConfigs[$0] } + let host = config?.host ?? reference.inlineHost ?? "" + guard !host.isEmpty else { return nil } + + let authType = (config?.authType ?? "PASSWORD").uppercased() + let usesKey = authType == "KEY_PAIR" || authType == "PUBLIC_KEY" + let keyPath = config?.keyPath ?? "" + + return ExportableSSHConfig( + enabled: true, + host: host, + port: config?.port ?? reference.inlinePort, + username: config?.username ?? reference.inlineUser ?? "", + authMethod: usesKey ? "Private Key" : "Password", + privateKeyPath: usesKey ? ForeignAppPathHelper.resolveKeyPath(keyPath) : "", + agentSocketPath: "", + jumpHosts: nil, + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + } + + private func makeSSLConfig(_ ssl: DataGripSSLProperties?) -> ExportableSSLConfig? { + guard let ssl else { return nil } + + let mode: String + switch (ssl.mode ?? "").lowercased() { + case "require", "required": mode = SSLMode.required.rawValue + case "verify_ca", "verify-ca": mode = SSLMode.verifyCa.rawValue + case "verify_full", "verify-full": mode = SSLMode.verifyIdentity.rawValue + default: mode = SSLMode.preferred.rawValue + } + + return ExportableSSLConfig( + mode: mode, + caCertificatePath: ssl.caCertPath, + clientCertificatePath: ssl.clientCertPath, + clientKeyPath: ssl.clientKeyPath + ) + } + + private func jdbcSubprotocol(_ url: String) -> String { + guard url.lowercased().hasPrefix("jdbc:") else { return "" } + var subprotocol = "" + for character in url.dropFirst("jdbc:".count) { + if character == ":" || character == "/" { break } + subprotocol.append(character) + } + return subprotocol + } + + private func mapDriverRef(_ driverRef: String, subprotocol: String) -> String { + let token = driverRef.lowercased().split(separator: ".").first.map(String.init) ?? driverRef.lowercased() + switch token { + case "mysql": return "MySQL" + case "mariadb": return "MariaDB" + case "postgresql", "postgres": return "PostgreSQL" + case "sqlite": return "SQLite" + case "sqlserver", "mssql", "jtds": return "SQL Server" + case "oracle": return "Oracle" + case "mongo", "mongodb": return "MongoDB" + case "redis": return "Redis" + case "clickhouse": return "ClickHouse" + case "cassandra": return "Cassandra" + case "duckdb": return "DuckDB" + case "bigquery": return "BigQuery" + case "cockroach", "cockroachdb": return "CockroachDB" + case "redshift": return "Redshift" + default: return mapSubprotocol(subprotocol, fallback: driverRef) + } + } + + private func mapSubprotocol(_ subprotocol: String, fallback: String) -> String { + switch subprotocol.lowercased() { + case "mysql": return "MySQL" + case "mariadb": return "MariaDB" + case "postgresql": return "PostgreSQL" + case "sqlite": return "SQLite" + case "sqlserver", "jtds": return "SQL Server" + case "oracle": return "Oracle" + case "mongodb": return "MongoDB" + case "redis": return "Redis" + case "clickhouse": return "ClickHouse" + case "cassandra": return "Cassandra" + case "duckdb": return "DuckDB" + case "bigquery": return "BigQuery" + default: return fallback + } + } + + private func defaultPort(for type: String) -> Int { + switch type { + case "MySQL", "MariaDB": return 3_306 + case "PostgreSQL", "CockroachDB", "Redshift": return 5_432 + case "MongoDB": return 27_017 + case "Redis": return 6_379 + case "SQL Server": return 1_433 + case "Oracle": return 1_521 + case "ClickHouse": return 8_123 + case "Cassandra": return 9_042 + default: return 0 + } + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 545b5726f..418417371 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -80,6 +80,7 @@ enum ForeignAppImporterRegistry { TablePlusImporter(), SequelAceImporter(), DBeaverImporter(), + DataGripImporter(), BeekeeperStudioImporter() ] } diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift new file mode 100644 index 000000000..25a48467d --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift @@ -0,0 +1,185 @@ +// +// JetBrainsCredentialStore.swift +// TablePro +// + +import CommonCrypto +import Foundation +import os +import Security + +/// Resolves a JetBrains data-source password. On macOS the IDE defaults to the +/// native Keychain (service `IntelliJ Platform DB — `); only the "In +/// KeePass" mode writes the encrypted `c.kdbx`, so the Keychain path is tried +/// first and the KDBX file is a fallback. +final class JetBrainsCredentialStore { + enum Lookup { + case found(String) + case notFound + case cancelled + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "JetBrainsCredentialStore") + + /// ASCII bytes of "Proxy Config Sec", the hardcoded AES-128 key the IDE uses + /// for the BUILT_IN encryption of the KDBX main key in `c.pwd`. + private static let builtInKey: [UInt8] = Array("Proxy Config Sec".utf8) + + private let configDir: URL + private var kdbxEntriesByTitle: [String: KdbxEntry]? + private var kdbxLoaded = false + + init(configDir: URL) { + self.configDir = configDir + } + + static func serviceName(forDataSourceUUID uuid: String) -> String { + "IntelliJ Platform DB \u{2014} \(uuid)" + } + + func password(forDataSourceUUID uuid: String) -> Lookup { + let service = Self.serviceName(forDataSourceUUID: uuid) + + switch readKeychain(service: service) { + case .found(let value): return .found(value) + case .cancelled: return .cancelled + case .notFound: break + } + + if let entry = loadKdbxEntries()?[service], !entry.password.isEmpty { + return .found(entry.password) + } + return .notFound + } + + // MARK: - Keychain + + private func readKeychain(service: String) -> Lookup { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data, let value = String(data: data, encoding: .utf8) else { + return .notFound + } + return .found(value) + case errSecItemNotFound: + return .notFound + default: + Self.logger.debug("Keychain read denied or cancelled for \(service): \(status)") + return .cancelled + } + } + + // MARK: - KDBX + + private func loadKdbxEntries() -> [String: KdbxEntry]? { + if kdbxLoaded { return kdbxEntriesByTitle } + kdbxLoaded = true + + let kdbxURL = configDir.appendingPathComponent("c.kdbx") + guard FileManager.default.fileExists(atPath: kdbxURL.path), + let fileData = try? Data(contentsOf: kdbxURL), + let mainKey = loadMainKey() else { return nil } + + do { + let entries = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + kdbxEntriesByTitle = Dictionary(entries.map { ($0.title, $0) }, uniquingKeysWith: { first, _ in first }) + } catch { + Self.logger.warning("Failed to read c.kdbx: \(error.localizedDescription)") + } + return kdbxEntriesByTitle + } + + private func loadMainKey() -> [UInt8]? { + for fileName in ["c.pwd", "pdb.pwd"] { + let url = configDir.appendingPathComponent(fileName) + guard let text = try? String(contentsOf: url, encoding: .utf8), + let parsed = parseMainKeyFile(text) else { continue } + guard parsed.encryption == "BUILT_IN" else { + Self.logger.warning("Unsupported c.pwd encryption: \(parsed.encryption)") + continue + } + if let key = decryptBuiltIn(parsed.value) { + return key + } + } + return nil + } + + private func parseMainKeyFile(_ text: String) -> (encryption: String, value: [UInt8])? { + var encryption = "BUILT_IN" + var base64Parts: [String] = [] + var capturingValue = false + + for line in text.components(separatedBy: .newlines) { + if capturingValue { + if let first = line.first, first == " " || first == "\t" { + base64Parts.append(line) + continue + } + capturingValue = false + } + if let range = line.range(of: "encryption:") { + encryption = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + } else if let range = line.range(of: "value:") { + let rest = String(line[range.upperBound...]) + .replacingOccurrences(of: "!!binary", with: "") + .trimmingCharacters(in: .whitespaces) + if rest.isEmpty || rest == "|" || rest == "|-" || rest == ">" { + capturingValue = true + } else { + base64Parts.append(rest) + } + } + } + + let alphabet = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") + let base64 = String(base64Parts.joined().filter { alphabet.contains($0) }) + guard let data = Data(base64Encoded: base64) else { return nil } + return (encryption, [UInt8](data)) + } + + private func decryptBuiltIn(_ blob: [UInt8]) -> [UInt8]? { + guard blob.count > 4 else { return nil } + let ivLength = Int(bigEndianUInt32(blob, 0)) + guard ivLength == kCCBlockSizeAES128, blob.count > 4 + ivLength else { return nil } + let iv = Array(blob[4..<4 + ivLength]) + let ciphertext = Array(blob[(4 + ivLength)...]) + return aesCBCDecrypt(ciphertext, key: Self.builtInKey, iv: iv) + } + + private func aesCBCDecrypt(_ ciphertext: [UInt8], key: [UInt8], iv: [UInt8]) -> [UInt8]? { + var output = [UInt8](repeating: 0, count: ciphertext.count + kCCBlockSizeAES128) + var outputLength = 0 + let status = CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + key, + key.count, + iv, + ciphertext, + ciphertext.count, + &output, + output.count, + &outputLength + ) + guard status == kCCSuccess else { return nil } + return Array(output.prefix(outputLength)) + } + + private func bigEndianUInt32(_ data: [UInt8], _ index: Int) -> UInt32 { + (UInt32(data[index]) << 24) + | (UInt32(data[index + 1]) << 16) + | (UInt32(data[index + 2]) << 8) + | UInt32(data[index + 3]) + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift new file mode 100644 index 000000000..d636d74a0 --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift @@ -0,0 +1,349 @@ +// +// KdbxDatabase.swift +// TablePro +// + +import CommonCrypto +import Compression +import Foundation +import os + +enum KdbxError: Error { + case malformedHeader + case unsupportedVersion + case wrongKey + case corruptedData +} + +struct KdbxEntry { + let title: String + let userName: String + let password: String +} + +/// Reader for the KDBX 3.1 file (`c.kdbx`) that JetBrains IDEs write with their +/// own `com.intellij.credentialStore` implementation. Decryption is AES-KDF +/// (iterated AES-256-ECB) plus AES-256-CBC, so CommonCrypto is sufficient. +enum KdbxDatabase { + private static let logger = Logger(subsystem: "com.TablePro", category: "KdbxDatabase") + + private static let sig1: UInt32 = 0x9AA2_D903 + private static let sig2: UInt32 = 0xB54B_FB67 + + private struct Header { + var mainSeed: [UInt8] = [] + var transformSeed: [UInt8] = [] + var transformRounds: UInt64 = 0 + var encryptionIV: [UInt8] = [] + var protectedStreamKey: [UInt8] = [] + var streamStartBytes: [UInt8] = [] + var innerStreamID: UInt32 = 0 + var compression: UInt32 = 0 + } + + static func read(fileData: Data, mainKey: [UInt8]) throws -> [KdbxEntry] { + let bytes = [UInt8](fileData) + let (header, payloadOffset) = try parseHeader(bytes) + + let finalKey = try deriveFinalKey(header: header, mainKey: mainKey) + let payload = Array(bytes[payloadOffset...]) + + guard let plaintext = aesCBCDecrypt(payload, key: finalKey, iv: header.encryptionIV) else { + throw KdbxError.corruptedData + } + guard plaintext.count >= 32, Array(plaintext.prefix(32)) == header.streamStartBytes else { + throw KdbxError.wrongKey + } + + guard let deframed = dehashBlocks(Array(plaintext.dropFirst(32))) else { + throw KdbxError.corruptedData + } + let xmlBytes = header.compression == 1 ? (gunzip(deframed) ?? []) : deframed + guard !xmlBytes.isEmpty else { throw KdbxError.corruptedData } + + let cipher = makeInnerCipher(id: header.innerStreamID, streamKey: header.protectedStreamKey) + return parseEntries(Data(xmlBytes), cipher: cipher) + } + + // MARK: - Header + + private static func parseHeader(_ data: [UInt8]) throws -> (Header, Int) { + guard data.count > 12, + readUInt32LE(data, 0) == sig1, + readUInt32LE(data, 4) == sig2 else { + throw KdbxError.malformedHeader + } + let version = readUInt32LE(data, 8) + guard (version & 0xFFFF_0000) <= 0x0003_0000 else { throw KdbxError.unsupportedVersion } + + var header = Header() + var pos = 12 + while pos + 3 <= data.count { + let fieldType = data[pos] + let length = Int(readUInt16LE(data, pos + 1)) + pos += 3 + guard pos + length <= data.count else { throw KdbxError.malformedHeader } + let field = Array(data[pos.. [UInt8] { + let composite = sha256(sha256(mainKey)) + guard let transformed = aesKdf( + input: composite, + seed: header.transformSeed, + rounds: header.transformRounds + ) else { + throw KdbxError.corruptedData + } + return sha256(header.mainSeed + sha256(transformed)) + } + + private static func aesKdf(input: [UInt8], seed: [UInt8], rounds: UInt64) -> [UInt8]? { + guard input.count == 32, seed.count == kCCKeySizeAES256 else { return nil } + + var cryptor: CCCryptorRef? + let createStatus = CCCryptorCreate( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + seed, + seed.count, + nil, + &cryptor + ) + guard createStatus == kCCSuccess, let cryptor else { return nil } + defer { CCCryptorRelease(cryptor) } + + var current = input + var next = [UInt8](repeating: 0, count: 32) + for _ in 0.. [UInt8]? { + var result: [UInt8] = [] + var pos = 0 + while pos + 40 <= data.count { + let hashStart = pos + 4 + let size = Int(readUInt32LE(data, pos + 36)) + pos += 40 + if size == 0 { return result } + guard pos + size <= data.count else { return nil } + let block = Array(data[pos.. KdbxInnerStreamCipher? { + switch id { + case 2: + return Salsa20Cipher(key: sha256(streamKey), nonce: Salsa20Cipher.keePassNonce) + case 3: + let digest = sha512(streamKey) + return ChaCha20Cipher(key: Array(digest[0..<32]), nonce: Array(digest[32..<44])) + default: + logger.warning("Unsupported KDBX inner stream id \(id); passwords will be skipped") + return nil + } + } + + // MARK: - XML + + private static func parseEntries(_ xml: Data, cipher: KdbxInnerStreamCipher?) -> [KdbxEntry] { + guard let document = try? XMLDocument(data: xml) else { return [] } + + var decryptedValues: [ObjectIdentifier: String] = [:] + if var activeCipher = cipher, let valueNodes = try? document.nodes(forXPath: "//Value") { + for node in valueNodes { + guard let element = node as? XMLElement, + (element.attribute(forName: "Protected")?.stringValue ?? "").lowercased() == "true", + let encoded = element.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + let data = Data(base64Encoded: encoded) else { continue } + let plain = activeCipher.process([UInt8](data)) + decryptedValues[ObjectIdentifier(element)] = String(bytes: plain, encoding: .utf8) ?? "" + } + } + + guard let entryNodes = try? document.nodes(forXPath: "//Entry") else { return [] } + var entries: [KdbxEntry] = [] + for node in entryNodes { + guard let entry = node as? XMLElement else { continue } + entries.append(parseEntry(entry, decryptedValues: decryptedValues)) + } + return entries + } + + private static func parseEntry(_ entry: XMLElement, decryptedValues: [ObjectIdentifier: String]) -> KdbxEntry { + var title = "" + var userName = "" + var password = "" + for stringElement in entry.elements(forName: "String") { + let key = stringElement.elements(forName: "Key").first?.stringValue ?? "" + guard let valueElement = stringElement.elements(forName: "Value").first else { continue } + switch key { + case "Title": title = valueElement.stringValue ?? "" + case "UserName": userName = valueElement.stringValue ?? "" + case "Password": + password = decryptedValues[ObjectIdentifier(valueElement)] ?? (valueElement.stringValue ?? "") + default: break + } + } + return KdbxEntry(title: title, userName: userName, password: password) + } + + // MARK: - Crypto Primitives + + private static func aesCBCDecrypt(_ ciphertext: [UInt8], key: [UInt8], iv: [UInt8]) -> [UInt8]? { + guard !ciphertext.isEmpty, iv.count == kCCBlockSizeAES128 else { return nil } + var output = [UInt8](repeating: 0, count: ciphertext.count + kCCBlockSizeAES128) + var outputLength = 0 + let status = CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + key, + key.count, + iv, + ciphertext, + ciphertext.count, + &output, + output.count, + &outputLength + ) + guard status == kCCSuccess else { return nil } + return Array(output.prefix(outputLength)) + } + + private static func sha256(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256(data, CC_LONG(data.count), &hash) + return hash + } + + private static func sha512(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + CC_SHA512(data, CC_LONG(data.count), &hash) + return hash + } + + // MARK: - GZIP + + private static func gunzip(_ data: [UInt8]) -> [UInt8]? { + guard data.count > 18, data[0] == 0x1f, data[1] == 0x8b, data[2] == 0x08 else { return nil } + let flags = data[3] + var offset = 10 + if flags & 0x04 != 0 { + guard offset + 2 <= data.count else { return nil } + let extraLength = Int(readUInt16LE(data, offset)) + offset += 2 + extraLength + } + if flags & 0x08 != 0 { + while offset < data.count, data[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x10 != 0 { + while offset < data.count, data[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x02 != 0 { + offset += 2 + } + guard offset < data.count - 8 else { return nil } + return inflateRawDeflate(Array(data[offset..<(data.count - 8)])) + } + + private static func inflateRawDeflate(_ input: [UInt8]) -> [UInt8]? { + let bufferSize = 65_536 + let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { destination.deallocate() } + + var stream = compression_stream( + dst_ptr: destination, + dst_size: bufferSize, + src_ptr: UnsafePointer(destination), + src_size: 0, + state: nil + ) + guard compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB) == COMPRESSION_STATUS_OK else { + return nil + } + defer { compression_stream_destroy(&stream) } + + return input.withUnsafeBufferPointer { source -> [UInt8]? in + guard let base = source.baseAddress else { return nil } + stream.src_ptr = base + stream.src_size = source.count + stream.dst_ptr = destination + stream.dst_size = bufferSize + + var output: [UInt8] = [] + while true { + let status = compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) + switch status { + case COMPRESSION_STATUS_OK: + if stream.dst_size == 0 { + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize)) + stream.dst_ptr = destination + stream.dst_size = bufferSize + } + case COMPRESSION_STATUS_END: + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize - stream.dst_size)) + return output + default: + return nil + } + } + } + } + + // MARK: - Little-Endian Readers + + private static func readUInt16LE(_ data: [UInt8], _ index: Int) -> UInt16 { + UInt16(data[index]) | (UInt16(data[index + 1]) << 8) + } + + private static func readUInt32LE(_ data: [UInt8], _ index: Int) -> UInt32 { + UInt32(data[index]) + | (UInt32(data[index + 1]) << 8) + | (UInt32(data[index + 2]) << 16) + | (UInt32(data[index + 3]) << 24) + } + + private static func readUInt64LE(_ data: [UInt8], _ index: Int) -> UInt64 { + var value: UInt64 = 0 + for offset in 0..<8 { + value |= UInt64(data[index + offset]) << (8 * offset) + } + return value + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift new file mode 100644 index 000000000..e19a1b4ea --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift @@ -0,0 +1,180 @@ +// +// KdbxInnerStreamCipher.swift +// TablePro +// + +import Foundation + +protocol KdbxInnerStreamCipher { + mutating func process(_ data: [UInt8]) -> [UInt8] +} + +/// IETF ChaCha20 (RFC 8439). KeePass derives key and nonce from SHA-512 of the +/// protected-stream key: key = digest[0..<32], nonce = digest[32..<44]. +struct ChaCha20Cipher: KdbxInnerStreamCipher { + private var state: [UInt32] + private var keyStream: [UInt8] = [] + private var offset = 0 + + init(key: [UInt8], nonce: [UInt8]) { + var initial = [UInt32](repeating: 0, count: 16) + initial[0] = 0x6170_7865 + initial[1] = 0x3320_646e + initial[2] = 0x7962_2d32 + initial[3] = 0x6b20_6574 + for index in 0..<8 { + initial[4 + index] = Self.load32(key, index * 4) + } + initial[12] = 0 + for index in 0..<3 { + initial[13 + index] = Self.load32(nonce, index * 4) + } + state = initial + } + + mutating func process(_ data: [UInt8]) -> [UInt8] { + var output = [UInt8](repeating: 0, count: data.count) + for index in 0..= keyStream.count { + keyStream = nextBlock() + offset = 0 + } + output[index] = data[index] ^ keyStream[offset] + offset += 1 + } + return output + } + + private mutating func nextBlock() -> [UInt8] { + var working = state + for _ in 0..<10 { + Self.quarterRound(&working, 0, 4, 8, 12) + Self.quarterRound(&working, 1, 5, 9, 13) + Self.quarterRound(&working, 2, 6, 10, 14) + Self.quarterRound(&working, 3, 7, 11, 15) + Self.quarterRound(&working, 0, 5, 10, 15) + Self.quarterRound(&working, 1, 6, 11, 12) + Self.quarterRound(&working, 2, 7, 8, 13) + Self.quarterRound(&working, 3, 4, 9, 14) + } + var block = [UInt8](repeating: 0, count: 64) + for index in 0..<16 { + Self.store32(working[index] &+ state[index], &block, index * 4) + } + state[12] = state[12] &+ 1 + return block + } + + private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { + s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = rotl(s[d], 16) + s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = rotl(s[b], 12) + s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = rotl(s[d], 8) + s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = rotl(s[b], 7) + } + + private static func rotl(_ value: UInt32, _ count: UInt32) -> UInt32 { + (value << count) | (value >> (32 - count)) + } + + private static func load32(_ bytes: [UInt8], _ index: Int) -> UInt32 { + UInt32(bytes[index]) + | (UInt32(bytes[index + 1]) << 8) + | (UInt32(bytes[index + 2]) << 16) + | (UInt32(bytes[index + 3]) << 24) + } + + private static func store32(_ value: UInt32, _ bytes: inout [UInt8], _ index: Int) { + bytes[index] = UInt8(value & 0xff) + bytes[index + 1] = UInt8((value >> 8) & 0xff) + bytes[index + 2] = UInt8((value >> 16) & 0xff) + bytes[index + 3] = UInt8((value >> 24) & 0xff) + } +} + +/// Salsa20 with the fixed KeePass nonce 0xE830094B97205D2A and key = SHA-256 of +/// the protected-stream key. Used by KDBX files written before the ChaCha20 default. +struct Salsa20Cipher: KdbxInnerStreamCipher { + static let keePassNonce: [UInt8] = [0xe8, 0x30, 0x09, 0x4b, 0x97, 0x20, 0x5d, 0x2a] + + private var state: [UInt32] + private var keyStream: [UInt8] = [] + private var offset = 0 + + init(key: [UInt8], nonce: [UInt8]) { + var initial = [UInt32](repeating: 0, count: 16) + initial[0] = 0x6170_7865 + initial[5] = 0x3320_646e + initial[10] = 0x7962_2d32 + initial[15] = 0x6b20_6574 + for index in 0..<4 { + initial[1 + index] = Self.load32(key, index * 4) + initial[11 + index] = Self.load32(key, 16 + index * 4) + } + initial[6] = Self.load32(nonce, 0) + initial[7] = Self.load32(nonce, 4) + initial[8] = 0 + initial[9] = 0 + state = initial + } + + mutating func process(_ data: [UInt8]) -> [UInt8] { + var output = [UInt8](repeating: 0, count: data.count) + for index in 0..= keyStream.count { + keyStream = nextBlock() + offset = 0 + } + output[index] = data[index] ^ keyStream[offset] + offset += 1 + } + return output + } + + private mutating func nextBlock() -> [UInt8] { + var working = state + for _ in 0..<10 { + Self.quarterRound(&working, 0, 4, 8, 12) + Self.quarterRound(&working, 5, 9, 13, 1) + Self.quarterRound(&working, 10, 14, 2, 6) + Self.quarterRound(&working, 15, 3, 7, 11) + Self.quarterRound(&working, 0, 1, 2, 3) + Self.quarterRound(&working, 5, 6, 7, 4) + Self.quarterRound(&working, 10, 11, 8, 9) + Self.quarterRound(&working, 15, 12, 13, 14) + } + var block = [UInt8](repeating: 0, count: 64) + for index in 0..<16 { + Self.store32(working[index] &+ state[index], &block, index * 4) + } + state[8] = state[8] &+ 1 + if state[8] == 0 { + state[9] = state[9] &+ 1 + } + return block + } + + private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { + s[b] ^= rotl(s[a] &+ s[d], 7) + s[c] ^= rotl(s[b] &+ s[a], 9) + s[d] ^= rotl(s[c] &+ s[b], 13) + s[a] ^= rotl(s[d] &+ s[c], 18) + } + + private static func rotl(_ value: UInt32, _ count: UInt32) -> UInt32 { + (value << count) | (value >> (32 - count)) + } + + private static func load32(_ bytes: [UInt8], _ index: Int) -> UInt32 { + UInt32(bytes[index]) + | (UInt32(bytes[index + 1]) << 8) + | (UInt32(bytes[index + 2]) << 16) + | (UInt32(bytes[index + 3]) << 24) + } + + private static func store32(_ value: UInt32, _ bytes: inout [UInt8], _ index: Int) { + bytes[index] = UInt8(value & 0xff) + bytes[index + 1] = UInt8((value >> 8) & 0xff) + bytes[index + 2] = UInt8((value >> 16) & 0xff) + bytes[index + 3] = UInt8((value >> 24) & 0xff) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift new file mode 100644 index 000000000..b3398b6f3 --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -0,0 +1,240 @@ +// +// DataGripImporterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("DataGripImporter", .serialized) +struct DataGripImporterTests { + private let root: URL + private let optionsDir: URL + private var importer: DataGripImporter + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("DataGripImporterTests-\(UUID().uuidString)") + optionsDir = root.appendingPathComponent("DataGrip2025.1/options") + try FileManager.default.createDirectory(at: optionsDir, withIntermediateDirectories: true) + + var imp = DataGripImporter() + imp.jetBrainsRoot = root + importer = imp + } + + // MARK: - Fixtures + + private func writeDataSources(_ elements: [String]) throws { + let xml = """ + + + + \(elements.joined(separator: "\n")) + + + """ + try xml.write(to: optionsDir.appendingPathComponent("dataSources.xml"), atomically: true, encoding: .utf8) + } + + private func writeSSHConfig(_ configs: [String]) throws { + let xml = """ + + + + \(configs.joined(separator: "\n")) + + + + """ + try xml.write(to: optionsDir.appendingPathComponent("ssh-config.xml"), atomically: true, encoding: .utf8) + } + + private func source( + uuid: String, + name: String, + driverRef: String, + jdbcURL: String, + userName: String = "", + group: String? = nil, + extra: String = "" + ) -> String { + let groupAttr = group.map { " group-name=\"\($0)\"" } ?? "" + let userElement = userName.isEmpty ? "" : "\(userName)" + return """ + + \(driverRef) + \(jdbcURL) + \(userElement) + \(extra) + + """ + } + + // MARK: - Discovery + + @Test("connectionCount counts unique data sources") + func connectionCount() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + source(uuid: "2", name: "B", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/b") + ]) + #expect(importer.connectionCount() == 2) + } + + @Test("import throws when no DataGrip data found") + func noData() { + #expect(throws: ForeignAppImportError.self) { + try importer.importConnections(includePasswords: false) + } + } + + // MARK: - Mapping + + @Test("maps driver-ref to database types") + func driverMapping() throws { + try writeDataSources([ + source(uuid: "1", name: "my", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + source(uuid: "2", name: "pg", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/b"), + source(uuid: "3", name: "ms", driverRef: "sqlserver.ms", jdbcURL: "jdbc:sqlserver://h:1433;databaseName=c"), + source(uuid: "4", name: "or", driverRef: "oracle", jdbcURL: "jdbc:oracle:thin:@h:1521:ORCL"), + source(uuid: "5", name: "lt", driverRef: "sqlite.xerial", jdbcURL: "jdbc:sqlite:/tmp/x.db") + ]) + + let result = try importer.importConnections(includePasswords: false) + let types = Dictionary(uniqueKeysWithValues: result.envelope.connections.map { ($0.name, $0.type) }) + + #expect(types["my"] == "MySQL") + #expect(types["pg"] == "PostgreSQL") + #expect(types["ms"] == "SQL Server") + #expect(types["or"] == "Oracle") + #expect(types["lt"] == "SQLite") + } + + @Test("parses host, port and database from jdbc url") + func endpointParsing() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://db.example.com:3307/shop", userName: "root") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.host == "db.example.com") + #expect(connection.port == 3_307) + #expect(connection.database == "shop") + #expect(connection.username == "root") + } + + @Test("uses default port when jdbc url omits it") + func defaultPort() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://localhost/app") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.port == 5_432) + } + + @Test("SQLite stores file path as database") + func sqlitePath() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "sqlite.xerial", jdbcURL: "jdbc:sqlite:/Users/me/app.db") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.type == "SQLite") + #expect(connection.database == "/Users/me/app.db") + } + + // MARK: - SSH + + @Test("joins SSH config by ssh-config-id") + func sshJoin() throws { + try writeDataSources([ + source( + uuid: "1", + name: "A", + driverRef: "mysql.8", + jdbcURL: "jdbc:mysql://h:3306/a", + extra: "trueSSH1" + ) + ]) + try writeSSHConfig([ + "" + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + let ssh = try #require(connection.sshConfig) + #expect(ssh.host == "bastion.example.com") + #expect(ssh.port == 2_222) + #expect(ssh.username == "deploy") + #expect(ssh.authMethod == "Private Key") + #expect(ssh.privateKeyPath == "/Users/me/.ssh/id_rsa") + } + + @Test("no SSH when properties disabled") + func sshDisabled() throws { + try writeDataSources([ + source( + uuid: "1", + name: "A", + driverRef: "mysql.8", + jdbcURL: "jdbc:mysql://h:3306/a", + extra: "false" + ) + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.sshConfig == nil) + } + + // MARK: - SSL + + @Test("parses SSL mode and certificate paths") + func sslParsing() throws { + try writeDataSources([ + source( + uuid: "1", + name: "A", + driverRef: "postgresql", + jdbcURL: "jdbc:postgresql://h:5432/a", + extra: """ + + true + verify-full + /certs/ca.pem + + """ + ) + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + let ssl = try #require(connection.sslConfig) + #expect(ssl.mode == "Verify Identity") + #expect(ssl.caCertificatePath == "/certs/ca.pem") + } + + // MARK: - Groups & Dedup + + @Test("group-name attribute becomes a group") + func groups() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a", group: "Production") + ]) + + let result = try importer.importConnections(includePasswords: false) + #expect(result.envelope.connections.first?.groupName == "Production") + #expect(result.envelope.groups?.contains { $0.name == "Production" } == true) + } + + @Test("deduplicates data sources by uuid") + func dedup() throws { + try writeDataSources([ + source(uuid: "dup", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + source(uuid: "dup", name: "A copy", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + + let result = try importer.importConnections(includePasswords: false) + #expect(result.envelope.connections.count == 1) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift b/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift new file mode 100644 index 000000000..86c59b267 --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift @@ -0,0 +1,165 @@ +// +// JDBCConnectionStringTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("JDBCConnectionString") +struct JDBCConnectionStringTests { + @Test("MySQL with port and database") + func mysql() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:mysql://db.example.com:3307/shop", subprotocol: "mysql") + #expect(endpoint?.host == "db.example.com") + #expect(endpoint?.port == 3_307) + #expect(endpoint?.database == "shop") + } + + @Test("PostgreSQL strips query parameters") + func postgresWithParams() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:postgresql://localhost:5432/app?sslmode=require&user=x", + subprotocol: "postgresql" + ) + #expect(endpoint?.host == "localhost") + #expect(endpoint?.port == 5_432) + #expect(endpoint?.database == "app") + } + + @Test("MySQL without explicit port returns nil port") + func mysqlNoPort() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:mysql://localhost/app", subprotocol: "mysql") + #expect(endpoint?.host == "localhost") + #expect(endpoint?.port == nil) + #expect(endpoint?.database == "app") + } + + @Test("Authority strips user info") + func userInfo() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:mysql://root:pw@host:3306/db", subprotocol: "mysql") + #expect(endpoint?.host == "host") + #expect(endpoint?.port == 3_306) + } + + @Test("IPv6 host in brackets") + func ipv6() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:postgresql://[::1]:5432/db", subprotocol: "postgresql") + #expect(endpoint?.host == "::1") + #expect(endpoint?.port == 5_432) + #expect(endpoint?.database == "db") + } + + @Test("SQL Server uses semicolon properties for database") + func sqlServerSemicolon() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:sqlserver://sql.example.com:1433;databaseName=Sales;encrypt=true", + subprotocol: "sqlserver" + ) + #expect(endpoint?.host == "sql.example.com") + #expect(endpoint?.port == 1_433) + #expect(endpoint?.database == "Sales") + } + + @Test("SQL Server strips named instance") + func sqlServerInstance() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:sqlserver://host\\SQLEXPRESS;databaseName=db", + subprotocol: "sqlserver" + ) + #expect(endpoint?.host == "host") + #expect(endpoint?.database == "db") + } + + @Test("jTDS SQL Server path form") + func jtds() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:jtds:sqlserver://host:1433/mydb", + subprotocol: "jtds" + ) + #expect(endpoint?.host == "host") + #expect(endpoint?.port == 1_433) + #expect(endpoint?.database == "mydb") + } + + @Test("Oracle SID form") + func oracleSID() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:oracle:thin:@orahost:1521:ORCL", + subprotocol: "oracle" + ) + #expect(endpoint?.host == "orahost") + #expect(endpoint?.port == 1_521) + #expect(endpoint?.database == "ORCL") + } + + @Test("Oracle service name form with //") + func oracleService() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:oracle:thin:@//orahost:1521/PRODSVC", + subprotocol: "oracle" + ) + #expect(endpoint?.host == "orahost") + #expect(endpoint?.port == 1_521) + #expect(endpoint?.database == "PRODSVC") + } + + @Test("Oracle service name form without //") + func oracleServiceNoSlashes() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:oracle:thin:@orahost:1521/PRODSVC", + subprotocol: "oracle" + ) + #expect(endpoint?.host == "orahost") + #expect(endpoint?.port == 1_521) + #expect(endpoint?.database == "PRODSVC") + } + + @Test("SQLite file path") + func sqlite() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:sqlite:/Users/me/data/app.db", + subprotocol: "sqlite" + ) + #expect(endpoint?.host == "") + #expect(endpoint?.port == nil) + #expect(endpoint?.database == "/Users/me/data/app.db") + } + + @Test("DuckDB file path") + func duckdb() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:duckdb:/tmp/analytics.duckdb", + subprotocol: "duckdb" + ) + #expect(endpoint?.database == "/tmp/analytics.duckdb") + } + + @Test("ClickHouse authority form") + func clickhouse() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:clickhouse://ch.example.com:8123/metrics", + subprotocol: "clickhouse" + ) + #expect(endpoint?.host == "ch.example.com") + #expect(endpoint?.port == 8_123) + #expect(endpoint?.database == "metrics") + } + + @Test("MongoDB with authSource query") + func mongo() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:mongodb://mongo:27017/app?authSource=admin", + subprotocol: "mongodb" + ) + #expect(endpoint?.host == "mongo") + #expect(endpoint?.port == 27_017) + #expect(endpoint?.database == "app") + } + + @Test("Non-jdbc url returns nil") + func notJdbc() { + #expect(JDBCConnectionString.parse(url: "mysql://x/y", subprotocol: "mysql") == nil) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift new file mode 100644 index 000000000..cf68a3e5a --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift @@ -0,0 +1,53 @@ +// +// JetBrainsCredentialStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("JetBrainsCredentialStore", .serialized) +struct JetBrainsCredentialStoreTests { + private let configDir: URL + + init() throws { + configDir = FileManager.default.temporaryDirectory + .appendingPathComponent("JetBrainsCredentialStoreTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + } + + @Test("service name uses em-dash separator") + func serviceNameFormat() { + let name = JetBrainsCredentialStore.serviceName(forDataSourceUUID: "abc-123") + #expect(name == "IntelliJ Platform DB \u{2014} abc-123") + } + + @Test("reads password from c.kdbx via c.pwd main key") + func keePassFallback() throws { + let uuid = "a1b2c3" + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.serviceName(forDataSourceUUID: uuid) + + let kdbx = KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "u", password: "kdbx-secret") + try kdbx.write(to: configDir.appendingPathComponent("c.kdbx")) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .found(let password) = store.password(forDataSourceUUID: uuid) else { + Issue.record("Expected password to be found in KDBX") + return + } + #expect(password == "kdbx-secret") + } + + @Test("missing files return notFound") + func notFound() { + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .notFound = store.password(forDataSourceUUID: "missing") else { + Issue.record("Expected notFound when no keychain item and no c.kdbx") + return + } + } +} diff --git a/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift b/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift new file mode 100644 index 000000000..a4324b491 --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift @@ -0,0 +1,51 @@ +// +// KdbxDatabaseTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("KdbxDatabase") +struct KdbxDatabaseTests { + @Test("reads entry and decrypts ChaCha20-protected password") + func roundTrip() throws { + let mainKey: [UInt8] = Array("super-secret-main-key".utf8) + let title = "IntelliJ Platform DB \u{2014} a1b2c3" + let fileData = KdbxTestFixture.makeKdbx( + mainKey: mainKey, + title: title, + userName: "dbuser", + password: "p@ssw0rd!" + ) + + let entries = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + let entry = entries.first { $0.title == title } + + #expect(entry != nil) + #expect(entry?.userName == "dbuser") + #expect(entry?.password == "p@ssw0rd!") + } + + @Test("wrong main key is rejected by stream-start check") + func wrongKeyThrows() { + let fileData = KdbxTestFixture.makeKdbx( + mainKey: Array("correct".utf8), + title: "t", + userName: "u", + password: "p" + ) + + #expect(throws: (any Error).self) { + _ = try KdbxDatabase.read(fileData: fileData, mainKey: Array("incorrect".utf8)) + } + } + + @Test("malformed signature throws") + func malformedThrows() { + #expect(throws: (any Error).self) { + _ = try KdbxDatabase.read(fileData: Data([0x00, 0x01, 0x02, 0x03]), mainKey: []) + } + } +} diff --git a/TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift b/TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift new file mode 100644 index 000000000..a87d8972a --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift @@ -0,0 +1,64 @@ +// +// KdbxInnerStreamCipherTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ChaCha20Cipher") +struct ChaCha20CipherTests { + /// RFC 8439 A.1 Test Vector #1: key = 0, nonce = 0, counter starts at 0. + @Test("RFC 8439 keystream block 0") + func keystreamBlockZero() { + var cipher = ChaCha20Cipher(key: [UInt8](repeating: 0, count: 32), nonce: [UInt8](repeating: 0, count: 12)) + let keystream = cipher.process([UInt8](repeating: 0, count: 64)) + + let expected = hex(""" + 76b8e0ada0f13d90405d6ae55386bd28bdd219b8a08ded1aa836efcc8b770dc7 + da41597c5157488d7724e03fb8d84a376a43b8f41518a11cc387b669b2ee6586 + """) + #expect(keystream == expected) + } + + /// RFC 8439 A.1 Test Vector #2: same key/nonce, counter 1 -> second block. + @Test("RFC 8439 keystream blocks 0 and 1") + func keystreamTwoBlocks() { + var cipher = ChaCha20Cipher(key: [UInt8](repeating: 0, count: 32), nonce: [UInt8](repeating: 0, count: 12)) + let keystream = cipher.process([UInt8](repeating: 0, count: 128)) + + let secondBlock = hex(""" + 9f07e7be5551387a98ba977c732d080dcb0f29a048e3656912c6533e32ee7aed + 29b721769ce64e43d57133b074d839d531ed1f28510afb45ace10a1f4b794d6f + """) + #expect(Array(keystream[64..<128]) == secondBlock) + } + + @Test("Process is XOR involution") + func roundTrip() { + let key = (0..<32).map { UInt8($0) } + let nonce = (0..<12).map { UInt8($0) } + let plaintext: [UInt8] = Array("the quick brown fox".utf8) + + var encryptCipher = ChaCha20Cipher(key: key, nonce: nonce) + let ciphertext = encryptCipher.process(plaintext) + + var decryptCipher = ChaCha20Cipher(key: key, nonce: nonce) + #expect(decryptCipher.process(ciphertext) == plaintext) + } + + private func hex(_ string: String) -> [UInt8] { + let cleaned = string.filter(\.isHexDigit) + var bytes: [UInt8] = [] + var index = cleaned.startIndex + while index < cleaned.endIndex { + let next = cleaned.index(index, offsetBy: 2) + if let byte = UInt8(cleaned[index.. Data { + let mainSeed = randomBytes(32) + let transformSeed = randomBytes(32) + let encryptionIV = randomBytes(16) + let protectedStreamKey = randomBytes(32) + let streamStartBytes = randomBytes(32) + + let digest = sha512(protectedStreamKey) + var cipher = ChaCha20Cipher(key: Array(digest[0..<32]), nonce: Array(digest[32..<44])) + let protectedPassword = Data(cipher.process(Array(password.utf8))).base64EncodedString() + + let xml = """ + + IntelliJ Platform + + Title\(title) + UserName\(userName) + Password\(protectedPassword) + + """ + let xmlBytes = Array(xml.utf8) + + var framed: [UInt8] = [] + framed += le32(0) + framed += sha256(xmlBytes) + framed += le32(UInt32(xmlBytes.count)) + framed += xmlBytes + framed += le32(1) + framed += [UInt8](repeating: 0, count: 32) + framed += le32(0) + + let plaintext = streamStartBytes + framed + let composite = sha256(sha256(mainKey)) + let finalKey = sha256(mainSeed + sha256(aesKdf(composite, seed: transformSeed, rounds: rounds))) + let ciphertext = aesCBCEncrypt(plaintext, key: finalKey, iv: encryptionIV) + + var header: [UInt8] = [] + header += le32(0x9AA2_D903) + header += le32(0xB54B_FB67) + header += le32(0x0003_0001) + appendField(&header, 3, le32(0)) + appendField(&header, 4, mainSeed) + appendField(&header, 5, transformSeed) + appendField(&header, 6, le64(rounds)) + appendField(&header, 7, encryptionIV) + appendField(&header, 8, protectedStreamKey) + appendField(&header, 9, streamStartBytes) + appendField(&header, 10, le32(3)) + appendField(&header, 0, [0x0d, 0x0a, 0x0d, 0x0a]) + + return Data(header + ciphertext) + } + + /// Encodes `mainKey` the way `c.pwd` stores it: BUILT_IN AES-128-CBC under the + /// hardcoded "Proxy Config Sec" key, with a big-endian IV-length prefix. + static func makeMainKeyFile(mainKey: [UInt8]) -> String { + let iv = randomBytes(16) + let ciphertext = aesCBCEncrypt(mainKey, key: Array("Proxy Config Sec".utf8), iv: iv) + var blob = beBytes(UInt32(16)) + blob += iv + blob += ciphertext + let base64 = Data(blob).base64EncodedString() + return "isAutoGenerated: true\nvalue: !!binary \(base64)\nencryption: BUILT_IN\n" + } + + // MARK: - Crypto Helpers + + static func aesKdf(_ input: [UInt8], seed: [UInt8], rounds: UInt64) -> [UInt8] { + var cryptor: CCCryptorRef? + CCCryptorCreate( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + seed, + seed.count, + nil, + &cryptor + ) + guard let cryptor else { return input } + defer { CCCryptorRelease(cryptor) } + + var current = input + var next = [UInt8](repeating: 0, count: 32) + for _ in 0.. [UInt8] { + var output = [UInt8](repeating: 0, count: plaintext.count + kCCBlockSizeAES128) + var outputLength = 0 + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + key, + key.count, + iv, + plaintext, + plaintext.count, + &output, + output.count, + &outputLength + ) + return Array(output.prefix(outputLength)) + } + + static func sha256(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256(data, CC_LONG(data.count), &hash) + return hash + } + + static func sha512(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + CC_SHA512(data, CC_LONG(data.count), &hash) + return hash + } + + static func randomBytes(_ count: Int) -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: count) + _ = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) + return bytes + } + + // MARK: - Encoding Helpers + + private static func appendField(_ header: inout [UInt8], _ type: UInt8, _ data: [UInt8]) { + header.append(type) + header += le16(UInt16(data.count)) + header += data + } + + static func le16(_ value: UInt16) -> [UInt8] { + [UInt8(value & 0xff), UInt8((value >> 8) & 0xff)] + } + + static func le32(_ value: UInt32) -> [UInt8] { + (0..<4).map { UInt8((value >> (8 * $0)) & 0xff) } + } + + static func le64(_ value: UInt64) -> [UInt8] { + (0..<8).map { UInt8((value >> (8 * UInt64($0))) & 0xff) } + } + + static func beBytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff) + ] + } +} diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 9e017f330..d3133bd51 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -58,7 +58,7 @@ Duplicates are unchecked. Check to import, then pick **As Copy**, **Replace**, o ## Import from Other Apps -Bring your connections over from TablePlus, Sequel Ace, or DBeaver. Passwords can be imported too. The source app doesn't need to be running. +Bring your connections over from TablePlus, Sequel Ace, DBeaver, or DataGrip. Passwords can be imported too. The source app doesn't need to be running. 1. **File** > **Import from Other App...** 2. Pick the source app and click **Continue**. @@ -84,6 +84,7 @@ Groups and folders carry over. | TablePlus | MySQL, PostgreSQL, MongoDB, SQLite, Redis, and more | From Keychain | | Sequel Ace | MySQL | From Keychain | | DBeaver | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | Decrypted from config file | +| DataGrip | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | From Keychain or `c.kdbx` | ## Share via Link From 60134329643177569eea5dc9da25afcddfb0d7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 22 May 2026 13:29:07 +0700 Subject: [PATCH 2/3] fix(connections): correct DataGrip SSH/SSL import and credential handling --- CHANGELOG.md | 4 + .../DataGrip/DataGripDataSourceParser.swift | 117 ++++++++++------- .../Export/ForeignApp/DataGripImporter.swift | 78 ++++++----- .../JetBrains/JetBrainsCredentialStore.swift | 21 ++- .../JetBrains/JetBrainsPathMacros.swift | 14 ++ .../ForeignApp/DataGripImporterTests.swift | 123 +++++++++++++----- .../JetBrainsCredentialStoreTests.swift | 15 +++ .../ForeignApp/KdbxDatabaseTests.swift | 19 +++ .../Services/ForeignApp/KdbxTestFixture.swift | 77 +++++++++-- docs/features/connection-sharing.mdx | 2 + 10 files changed, 344 insertions(+), 126 deletions(-) create mode 100644 TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b43ba266d..f329228f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Import connections and passwords from DataGrip, including SSH tunnels and SSL settings. The source app doesn't need to be running. (#1374) + ## [0.43.2] - 2026-05-22 ### Changed diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift b/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift index 03741dbef..da233eb4e 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift @@ -10,12 +10,52 @@ struct DataGripDataSource { let name: String let driverRef: String let jdbcURL: String - var username: String + let username: String let groupName: String? let ssh: DataGripSSHReference? let ssl: DataGripSSLProperties? } +/// A single `` element. DataGrip splits one logical data source +/// across the shared `dataSources.xml` (driver, jdbc-url, group) and the +/// machine-local `dataSources.local.xml` (user name, ssh-properties, +/// ssl-properties). Fragments from both files merge by uuid before resolving. +struct DataGripDataSourceFragment { + let uuid: String + var name: String? + var driverRef: String? + var jdbcURL: String? + var username: String? + var groupName: String? + var ssh: DataGripSSHReference? + var ssl: DataGripSSLProperties? + + mutating func merge(_ other: DataGripDataSourceFragment) { + name = other.name ?? name + driverRef = other.driverRef ?? driverRef + jdbcURL = other.jdbcURL ?? jdbcURL + username = other.username ?? username + groupName = other.groupName ?? groupName + ssh = other.ssh ?? ssh + ssl = other.ssl ?? ssl + } + + func resolved() -> DataGripDataSource? { + guard let driverRef, !driverRef.isEmpty, + let jdbcURL, !jdbcURL.isEmpty else { return nil } + return DataGripDataSource( + uuid: uuid, + name: name ?? uuid, + driverRef: driverRef, + jdbcURL: jdbcURL, + username: username ?? "", + groupName: groupName, + ssh: ssh, + ssl: ssl + ) + } +} + struct DataGripSSHReference { let enabled: Bool let configId: String? @@ -36,18 +76,18 @@ struct DataGripSSHConfig { let host: String let port: Int? let username: String - let authType: String + let authType: String? let keyPath: String? } enum DataGripDataSourceParser { - static func parseDataSources(_ data: Data) -> [DataGripDataSource] { + static func parseFragments(_ data: Data) -> [DataGripDataSourceFragment] { guard let document = try? XMLDocument(data: data), let nodes = try? document.nodes(forXPath: "//data-source") else { return [] } return nodes.compactMap { node in - guard let element = node as? XMLElement else { return nil } - return parseDataSource(element) + guard let element = node as? XMLElement, let uuid = element.attr("uuid") else { return nil } + return parseFragment(element, uuid: uuid) } } @@ -64,51 +104,26 @@ enum DataGripDataSourceParser { host: element.attr("host") ?? "", port: element.attr("port").flatMap { Int($0) }, username: element.attr("username") ?? "", - authType: element.attr("authType") ?? "PASSWORD", - keyPath: element.attr("keyPath") + authType: element.attr("authType"), + keyPath: element.attr("keyPath").map { JetBrainsPathMacros.expand($0) } ) result[id] = config } return result } - /// `dataSources.local.xml` holds the user name and per-user secrets metadata - /// that the shared `dataSources.xml` omits. Returns user names keyed by data-source UUID. - static func parseLocalUserNames(_ data: Data) -> [String: String] { - guard let document = try? XMLDocument(data: data), - let nodes = try? document.nodes(forXPath: "//data-source") else { return [:] } - - var result: [String: String] = [:] - for node in nodes { - guard let element = node as? XMLElement, - let uuid = element.attr("uuid"), - let user = element.childText("user-name"), !user.isEmpty else { continue } - result[uuid] = user - } - return result - } - // MARK: - Private - private static func parseDataSource(_ element: XMLElement) -> DataGripDataSource? { - guard let uuid = element.attr("uuid"), - let driverRef = element.childText("driver-ref"), - let jdbcURL = element.childText("jdbc-url"), !jdbcURL.isEmpty else { return nil } - - let name = element.attr("name") ?? uuid - let username = element.childText("user-name") ?? "" - let groupName = element.attr("group-name").flatMap { $0.isEmpty ? nil : $0 } - - return DataGripDataSource( - uuid: uuid, - name: name, - driverRef: driverRef, - jdbcURL: jdbcURL, - username: username, - groupName: groupName, - ssh: parseSSHReference(element), - ssl: parseSSLProperties(element) - ) + private static func parseFragment(_ element: XMLElement, uuid: String) -> DataGripDataSourceFragment { + var fragment = DataGripDataSourceFragment(uuid: uuid) + fragment.name = element.attr("name").flatMap { $0.isEmpty ? nil : $0 } + fragment.driverRef = element.childText("driver-ref") + fragment.jdbcURL = element.childText("jdbc-url").flatMap { $0.isEmpty ? nil : $0 } + fragment.username = element.childText("user-name").flatMap { $0.isEmpty ? nil : $0 } + fragment.groupName = element.attr("group-name").flatMap { $0.isEmpty ? nil : $0 } + fragment.ssh = parseSSHReference(element) + fragment.ssl = parseSSLProperties(element) + return fragment } private static func parseSSHReference(_ element: XMLElement) -> DataGripSSHReference? { @@ -128,16 +143,14 @@ enum DataGripDataSourceParser { } private static func parseSSLProperties(_ element: XMLElement) -> DataGripSSLProperties? { - guard let ssl = element.elements(forName: "ssl-properties").first else { return nil } - - let enabled = (ssl.childText("enabled") ?? ssl.attr("enabled")) == "true" - guard enabled else { return nil } + guard let ssl = element.elements(forName: "ssl-config").first, + ssl.childText("enabled") == "true" else { return nil } return DataGripSSLProperties( - mode: ssl.childText("ssl-mode") ?? ssl.childText("mode") ?? ssl.attr("ssl-mode"), - caCertPath: ssl.childText("ca-file") ?? ssl.childText("ca-cert"), - clientCertPath: ssl.childText("client-cert-file") ?? ssl.childText("client-cert"), - clientKeyPath: ssl.childText("client-key-file") ?? ssl.childText("client-key") + mode: ssl.childText("mode"), + caCertPath: ssl.certPath("ca-cert"), + clientCertPath: ssl.certPath("client-cert"), + clientKeyPath: ssl.certPath("client-key") ) } } @@ -150,4 +163,8 @@ private extension XMLElement { func attr(_ name: String) -> String? { attribute(forName: name)?.stringValue } + + func certPath(_ name: String) -> String? { + childText(name).flatMap { $0.isEmpty ? nil : JetBrainsPathMacros.expand($0) } + } } diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift index 603d6de53..548421c24 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -23,7 +23,6 @@ struct DataGripImporter: ForeignAppImporter { private struct Location { let dataSourcesURL: URL let localURL: URL? - let sshConfigURL: URL let configDir: URL } @@ -34,8 +33,7 @@ struct DataGripImporter: ForeignAppImporter { func connectionCount() -> Int { var seen = Set() for location in locations() { - guard let data = try? Data(contentsOf: location.dataSourcesURL) else { continue } - for source in DataGripDataSourceParser.parseDataSources(data) { + for source in dataSources(at: location) { seen.insert(source.uuid) } } @@ -53,16 +51,17 @@ struct DataGripImporter: ForeignAppImporter { var groupNames = Set() var credentials: [String: ExportableCredentials] = [:] var credentialsAborted = false + var sshConfigsByDir: [URL: [String: DataGripSSHConfig]] = [:] for location in locations { - guard let data = try? Data(contentsOf: location.dataSourcesURL) else { continue } - var sources = DataGripDataSourceParser.parseDataSources(data) - mergeLocalUserNames(into: &sources, localURL: location.localURL) - - let sshConfigs = loadSSHConfigs(location) + let sshConfigs = sshConfigsByDir[location.configDir] ?? { + let loaded = loadSSHConfigs(configDir: location.configDir) + sshConfigsByDir[location.configDir] = loaded + return loaded + }() let credentialStore = includePasswords ? JetBrainsCredentialStore(configDir: location.configDir) : nil - for source in sources { + for source in dataSources(at: location) { guard seenUUIDs.insert(source.uuid).inserted, let connection = makeConnection(source, sshConfigs: sshConfigs) else { continue } @@ -162,7 +161,6 @@ struct DataGripImporter: ForeignAppImporter { result.append(Location( dataSourcesURL: dataSources, localURL: FileManager.default.fileExists(atPath: local.path) ? local : nil, - sshConfigURL: directory.appendingPathComponent("ssh-config.xml"), configDir: configDir )) } @@ -174,31 +172,39 @@ struct DataGripImporter: ForeignAppImporter { let nodes = try? document.nodes(forXPath: "//entry/@key") else { return [] } return nodes.compactMap { node in - node.stringValue.map { $0.replacingOccurrences(of: "$USER_HOME$", with: NSHomeDirectory()) } + node.stringValue.map { JetBrainsPathMacros.expand($0) } } } - private func loadSSHConfigs(_ location: Location) -> [String: DataGripSSHConfig] { - var merged: [String: DataGripSSHConfig] = [:] - let urls = [ - location.configDir.appendingPathComponent("options/ssh-config.xml"), - location.sshConfigURL - ] - for url in urls { - guard let data = try? Data(contentsOf: url) else { continue } - merged.merge(DataGripDataSourceParser.parseSSHConfigs(data)) { _, new in new } - } - return merged + /// DataGrip stores SSH connection details once per IDE under + /// `options/sshConfigs.xml`, keyed by id and referenced from each data + /// source's ``. + private func loadSSHConfigs(configDir: URL) -> [String: DataGripSSHConfig] { + let url = configDir.appendingPathComponent("options/sshConfigs.xml") + guard let data = try? Data(contentsOf: url) else { return [:] } + return DataGripDataSourceParser.parseSSHConfigs(data) } - private func mergeLocalUserNames(into sources: inout [DataGripDataSource], localURL: URL?) { - guard let localURL, let data = try? Data(contentsOf: localURL) else { return } - let userNames = DataGripDataSourceParser.parseLocalUserNames(data) - for index in sources.indices where sources[index].username.isEmpty { - if let user = userNames[sources[index].uuid] { - sources[index].username = user + /// Merges the shared `dataSources.xml` with the machine-local + /// `dataSources.local.xml`. The shared file carries the driver and JDBC URL; + /// the local file carries the user name, SSH and SSL properties. Fragments + /// join by uuid with the local file overriding the fields it provides. + private func dataSources(at location: Location) -> [DataGripDataSource] { + var fragments: [String: DataGripDataSourceFragment] = [:] + var order: [String] = [] + + for url in [location.dataSourcesURL, location.localURL].compactMap({ $0 }) { + guard let data = try? Data(contentsOf: url) else { continue } + for fragment in DataGripDataSourceParser.parseFragments(data) { + if fragments[fragment.uuid] == nil { + order.append(fragment.uuid) + fragments[fragment.uuid] = fragment + } else { + fragments[fragment.uuid]?.merge(fragment) + } } } + return order.compactMap { fragments[$0]?.resolved() } } // MARK: - Mapping @@ -247,9 +253,8 @@ struct DataGripImporter: ForeignAppImporter { let host = config?.host ?? reference.inlineHost ?? "" guard !host.isEmpty else { return nil } - let authType = (config?.authType ?? "PASSWORD").uppercased() - let usesKey = authType == "KEY_PAIR" || authType == "PUBLIC_KEY" let keyPath = config?.keyPath ?? "" + let usesKey = usesKeyAuthentication(authType: config?.authType, keyPath: keyPath) return ExportableSSHConfig( enabled: true, @@ -267,6 +272,19 @@ struct DataGripImporter: ForeignAppImporter { ) } + /// DataGrip omits `authType` when the connection relies on the OpenSSH + /// config, so a present key path is the reliable signal for key auth. + private func usesKeyAuthentication(authType: String?, keyPath: String) -> Bool { + switch (authType ?? "").uppercased() { + case "KEY_PAIR", "PUBLIC_KEY", "OPEN_SSH": + return true + case "PASSWORD": + return false + default: + return !keyPath.isEmpty + } + } + private func makeSSLConfig(_ ssl: DataGripSSLProperties?) -> ExportableSSLConfig? { guard let ssl else { return nil } diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift index 25a48467d..6fac32f3e 100644 --- a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift @@ -8,10 +8,15 @@ import Foundation import os import Security -/// Resolves a JetBrains data-source password. On macOS the IDE defaults to the -/// native Keychain (service `IntelliJ Platform DB — `); only the "In -/// KeePass" mode writes the encrypted `c.kdbx`, so the Keychain path is tried -/// first and the KDBX file is a fallback. +/// Resolves a JetBrains data-source password. The IDE names it with +/// `generateServiceName("DB", uuid)`, which formats as +/// `IntelliJ Platform DB \u{2014} `. On macOS the secret lives +/// in the native Keychain; only the "In KeePass" mode writes the encrypted +/// `c.kdbx`, so the Keychain is tried first and the KDBX is a fallback. +/// +/// SSH tunnel passwords are not recoverable: DataGrip does not persist them to +/// the Keychain (verified against a saved password-auth tunnel), so the importer +/// brings over the tunnel settings and the user re-enters the password. final class JetBrainsCredentialStore { enum Lookup { case found(String) @@ -28,6 +33,7 @@ final class JetBrainsCredentialStore { private let configDir: URL private var kdbxEntriesByTitle: [String: KdbxEntry]? private var kdbxLoaded = false + private var storeLocked = false init(configDir: URL) { self.configDir = configDir @@ -38,8 +44,10 @@ final class JetBrainsCredentialStore { } func password(forDataSourceUUID uuid: String) -> Lookup { - let service = Self.serviceName(forDataSourceUUID: uuid) + secret(service: Self.serviceName(forDataSourceUUID: uuid)) + } + private func secret(service: String) -> Lookup { switch readKeychain(service: service) { case .found(let value): return .found(value) case .cancelled: return .cancelled @@ -49,7 +57,7 @@ final class JetBrainsCredentialStore { if let entry = loadKdbxEntries()?[service], !entry.password.isEmpty { return .found(entry.password) } - return .notFound + return storeLocked ? .cancelled : .notFound } // MARK: - Keychain @@ -105,6 +113,7 @@ final class JetBrainsCredentialStore { let parsed = parseMainKeyFile(text) else { continue } guard parsed.encryption == "BUILT_IN" else { Self.logger.warning("Unsupported c.pwd encryption: \(parsed.encryption)") + storeLocked = true continue } if let key = decryptBuiltIn(parsed.value) { diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift new file mode 100644 index 000000000..4462ea56b --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift @@ -0,0 +1,14 @@ +// +// JetBrainsPathMacros.swift +// TablePro +// + +import Foundation + +/// Expands the path macros JetBrains IDEs write into their config XML, such as +/// `$USER_HOME$` in SSH key paths and recent-project entries. +enum JetBrainsPathMacros { + static func expand(_ path: String) -> String { + path.replacingOccurrences(of: "$USER_HOME$", with: NSHomeDirectory()) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift index b3398b6f3..301231a36 100644 --- a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -38,7 +38,19 @@ struct DataGripImporterTests { try xml.write(to: optionsDir.appendingPathComponent("dataSources.xml"), atomically: true, encoding: .utf8) } - private func writeSSHConfig(_ configs: [String]) throws { + private func writeLocalDataSources(_ elements: [String]) throws { + let xml = """ + + + + \(elements.joined(separator: "\n")) + + + """ + try xml.write(to: optionsDir.appendingPathComponent("dataSources.local.xml"), atomically: true, encoding: .utf8) + } + + private func writeSSHConfigs(_ configs: [String]) throws { let xml = """ @@ -48,7 +60,7 @@ struct DataGripImporterTests { """ - try xml.write(to: optionsDir.appendingPathComponent("ssh-config.xml"), atomically: true, encoding: .utf8) + try xml.write(to: optionsDir.appendingPathComponent("sshConfigs.xml"), atomically: true, encoding: .utf8) } private func source( @@ -72,6 +84,17 @@ struct DataGripImporterTests { """ } + private func localSource(uuid: String, name: String = "", userName: String = "", extra: String = "") -> String { + let nameAttr = name.isEmpty ? "" : " name=\"\(name)\"" + let userElement = userName.isEmpty ? "" : "\(userName)" + return """ + + \(userElement) + \(extra) + + """ + } + // MARK: - Discovery @Test("connectionCount counts unique data sources") @@ -148,70 +171,106 @@ struct DataGripImporterTests { // MARK: - SSH - @Test("joins SSH config by ssh-config-id") + @Test("joins SSH config from local file, infers key auth, expands $USER_HOME$") func sshJoin() throws { try writeDataSources([ - source( + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource( uuid: "1", - name: "A", - driverRef: "mysql.8", - jdbcURL: "jdbc:mysql://h:3306/a", + userName: "appuser", extra: "trueSSH1" ) ]) - try writeSSHConfig([ - "" + try writeSSHConfigs([ + """ + + """ ]) let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.username == "appuser") let ssh = try #require(connection.sshConfig) #expect(ssh.host == "bastion.example.com") #expect(ssh.port == 2_222) #expect(ssh.username == "deploy") #expect(ssh.authMethod == "Private Key") - #expect(ssh.privateKeyPath == "/Users/me/.ssh/id_rsa") + #expect(ssh.privateKeyPath == "\(NSHomeDirectory())/.ssh/id_ed25519") } - @Test("no SSH when properties disabled") - func sshDisabled() throws { + @Test("respects explicit password auth even with a key path") + func sshPasswordAuth() throws { try writeDataSources([ - source( + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource( uuid: "1", - name: "A", - driverRef: "mysql.8", - jdbcURL: "jdbc:mysql://h:3306/a", - extra: "false" + extra: "trueSSH1" ) ]) + try writeSSHConfigs([ + "" + ]) + + let ssh = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first?.sshConfig) + #expect(ssh.authMethod == "Password") + #expect(ssh.privateKeyPath == "") + } + + @Test("no SSH when properties disabled in local file") + func sshDisabled() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource(uuid: "1", extra: "false") + ]) let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) #expect(connection.sshConfig == nil) } + @Test("merges user-name from local file when shared file omits it") + func usernameFromLocalFile() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/db") + ]) + try writeLocalDataSources([ + localSource(uuid: "1", userName: "postgres") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.username == "postgres") + } + // MARK: - SSL - @Test("parses SSL mode and certificate paths") + @Test("parses SSL config from local file and expands $USER_HOME$") func sslParsing() throws { try writeDataSources([ - source( - uuid: "1", - name: "A", - driverRef: "postgresql", - jdbcURL: "jdbc:postgresql://h:5432/a", - extra: """ - - true - verify-full - /certs/ca.pem - - """ - ) + source(uuid: "1", name: "A", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/a") + ]) + try writeLocalDataSources([ + localSource(uuid: "1", extra: """ + + $USER_HOME$/certs/ca.pem + $USER_HOME$/certs/client.crt + $USER_HOME$/certs/client.key + true + VERIFY_FULL + + """) ]) let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) let ssl = try #require(connection.sslConfig) #expect(ssl.mode == "Verify Identity") - #expect(ssl.caCertificatePath == "/certs/ca.pem") + #expect(ssl.caCertificatePath == "\(NSHomeDirectory())/certs/ca.pem") + #expect(ssl.clientCertificatePath == "\(NSHomeDirectory())/certs/client.crt") + #expect(ssl.clientKeyPath == "\(NSHomeDirectory())/certs/client.key") } // MARK: - Groups & Dedup diff --git a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift index cf68a3e5a..d13dc6d47 100644 --- a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift +++ b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift @@ -50,4 +50,19 @@ struct JetBrainsCredentialStoreTests { return } } + + @Test("password-protected store reports locked instead of notFound") + func lockedStoreReportsCancelled() throws { + let mainKey = KdbxTestFixture.randomBytes(64) + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: "x", userName: "u", password: "p") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try "isAutoGenerated: false\nvalue: !!binary AAAA\nencryption: MASTER_KEY\n" + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .cancelled = store.password(forDataSourceUUID: "any") else { + Issue.record("Expected cancelled when c.pwd is protected by a master password") + return + } + } } diff --git a/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift b/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift index a4324b491..7106c386b 100644 --- a/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift +++ b/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift @@ -28,6 +28,25 @@ struct KdbxDatabaseTests { #expect(entry?.password == "p@ssw0rd!") } + @Test("reads gzip-compressed KDBX payload") + func compressedRoundTrip() throws { + let mainKey: [UInt8] = Array("compressed-main-key".utf8) + let title = "IntelliJ Platform DB \u{2014} gz" + let fileData = KdbxTestFixture.makeKdbx( + mainKey: mainKey, + title: title, + userName: "dbuser", + password: "p@ssw0rd!", + compressed: true + ) + + let entries = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + let entry = entries.first { $0.title == title } + + #expect(entry?.userName == "dbuser") + #expect(entry?.password == "p@ssw0rd!") + } + @Test("wrong main key is rejected by stream-start check") func wrongKeyThrows() { let fileData = KdbxTestFixture.makeKdbx( diff --git a/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift b/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift index 114ff0a99..58f1abadc 100644 --- a/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift +++ b/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift @@ -4,19 +4,22 @@ // import CommonCrypto +import Compression import Foundation @testable import TablePro /// Builds a synthetic KDBX 3.1 file matching JetBrains' layout so the reader can -/// be exercised end to end without a real `c.kdbx`. Uncompressed payload, -/// ChaCha20 inner stream, AES-256-CBC body, AES-KDF key transform. +/// be exercised end to end without a real `c.kdbx`. ChaCha20 inner stream, +/// AES-256-CBC body, AES-KDF key transform, with an optional gzip payload to +/// cover the compressed form real files use. enum KdbxTestFixture { static func makeKdbx( mainKey: [UInt8], title: String, userName: String, password: String, - rounds: UInt64 = 60 + rounds: UInt64 = 60, + compressed: Bool = false ) -> Data { let mainSeed = randomBytes(32) let transformSeed = randomBytes(32) @@ -37,13 +40,13 @@ enum KdbxTestFixture { Password\(protectedPassword) """ - let xmlBytes = Array(xml.utf8) + let payload = compressed ? gzip(Array(xml.utf8)) : Array(xml.utf8) var framed: [UInt8] = [] framed += le32(0) - framed += sha256(xmlBytes) - framed += le32(UInt32(xmlBytes.count)) - framed += xmlBytes + framed += sha256(payload) + framed += le32(UInt32(payload.count)) + framed += payload framed += le32(1) framed += [UInt8](repeating: 0, count: 32) framed += le32(0) @@ -57,7 +60,7 @@ enum KdbxTestFixture { header += le32(0x9AA2_D903) header += le32(0xB54B_FB67) header += le32(0x0003_0001) - appendField(&header, 3, le32(0)) + appendField(&header, 3, le32(compressed ? 1 : 0)) appendField(&header, 4, mainSeed) appendField(&header, 5, transformSeed) appendField(&header, 6, le64(rounds)) @@ -173,4 +176,62 @@ enum KdbxTestFixture { UInt8(value & 0xff) ] } + + // MARK: - GZIP + + private static func gzip(_ data: [UInt8]) -> [UInt8] { + var output: [UInt8] = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0xff] + output += rawDeflate(data) + output += le32(crc32(data)) + output += le32(UInt32(data.count & 0xffff_ffff)) + return output + } + + private static func rawDeflate(_ input: [UInt8]) -> [UInt8] { + let bufferSize = 65_536 + let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { destination.deallocate() } + + var stream = compression_stream(dst_ptr: destination, dst_size: bufferSize, src_ptr: destination, src_size: 0, state: nil) + guard compression_stream_init(&stream, COMPRESSION_STREAM_ENCODE, COMPRESSION_ZLIB) == COMPRESSION_STATUS_OK else { + return input + } + defer { compression_stream_destroy(&stream) } + + return input.withUnsafeBufferPointer { source -> [UInt8] in + guard let base = source.baseAddress else { return input } + stream.src_ptr = base + stream.src_size = source.count + stream.dst_ptr = destination + stream.dst_size = bufferSize + + var output: [UInt8] = [] + while true { + switch compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) { + case COMPRESSION_STATUS_OK: + if stream.dst_size == 0 { + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize)) + stream.dst_ptr = destination + stream.dst_size = bufferSize + } + case COMPRESSION_STATUS_END: + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize - stream.dst_size)) + return output + default: + return input + } + } + } + } + + private static func crc32(_ data: [UInt8]) -> UInt32 { + var crc: UInt32 = 0xffff_ffff + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xedb8_8320 : crc >> 1 + } + } + return crc ^ 0xffff_ffff + } } diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index d3133bd51..f3f98da85 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -86,6 +86,8 @@ Groups and folders carry over. | DBeaver | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | Decrypted from config file | | DataGrip | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | From Keychain or `c.kdbx` | +DataGrip stores connections per project. TablePro imports the connections from the projects DataGrip is tracking (its recent projects), along with their SSH tunnel and SSL settings. If a connection is missing, open its project in DataGrip once so it returns to the recent list. DataGrip doesn't store SSH tunnel passwords where TablePro can read them, so re-enter those after importing. Connections protected by a DataGrip master password can't be read. + ## Share via Link Two link forms ship with TablePro. Pick the one that matches what you want to share. From 40209893c472dd32a04f4d1f29b836123ac8587f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 22 May 2026 13:48:35 +0700 Subject: [PATCH 3/3] feat(connections): import DataGrip SSH tunnel passwords --- .../Export/ForeignApp/DataGripImporter.swift | 73 +++++++++++++++---- .../JetBrains/JetBrainsCredentialStore.swift | 33 +++++++-- .../ForeignApp/DataGripImporterTests.swift | 28 +++++++ .../JetBrainsCredentialStoreTests.swift | 26 +++++++ docs/features/connection-sharing.mdx | 2 +- 5 files changed, 140 insertions(+), 22 deletions(-) diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift index 548421c24..c0af9c53e 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -72,20 +72,11 @@ struct DataGripImporter: ForeignAppImporter { } if let store = credentialStore, !credentialsAborted { - switch store.password(forDataSourceUUID: source.uuid) { - case .found(let password): - credentials[String(index)] = ExportableCredentials( - password: password, - sshPassword: nil, - keyPassphrase: nil, - totpSecret: nil, - pluginSecureFields: nil - ) - case .cancelled: - credentialsAborted = true - case .notFound: - break + let collected = collectCredentials(for: source, sshConfigs: sshConfigs, store: store) + if let resolved = collected.credentials { + credentials[String(index)] = resolved } + credentialsAborted = collected.aborted } } } @@ -115,6 +106,62 @@ struct DataGripImporter: ForeignAppImporter { ) } + // MARK: - Credentials + + private struct CollectedCredentials { + var credentials: ExportableCredentials? + var aborted: Bool + } + + /// Reads the data-source password plus, when the connection tunnels over an + /// SSH config, its saved secret: a key passphrase for key auth or a password + /// otherwise. The SSH secret is keyed by `: `. `aborted` + /// is set when the user denies Keychain access so the caller stops prompting. + private func collectCredentials( + for source: DataGripDataSource, + sshConfigs: [String: DataGripSSHConfig], + store: JetBrainsCredentialStore + ) -> CollectedCredentials { + var password: String? + var sshPassword: String? + var keyPassphrase: String? + var aborted = false + + switch store.password(forDataSourceUUID: source.uuid) { + case .found(let value): password = value + case .cancelled: aborted = true + case .notFound: break + } + + if !aborted, let configId = source.ssh?.configId, let config = sshConfigs[configId] { + let host = config.host + let port = config.port ?? 22 + let usesKey = usesKeyAuthentication(authType: config.authType, keyPath: config.keyPath ?? "") + switch usesKey + ? store.sshKeyPassphrase(host: host, port: port, configId: configId) + : store.sshPassword(host: host, port: port, configId: configId) { + case .found(let value): + if usesKey { keyPassphrase = value } else { sshPassword = value } + case .cancelled: aborted = true + case .notFound: break + } + } + + guard password != nil || sshPassword != nil || keyPassphrase != nil else { + return CollectedCredentials(credentials: nil, aborted: aborted) + } + return CollectedCredentials( + credentials: ExportableCredentials( + password: password, + sshPassword: sshPassword, + keyPassphrase: keyPassphrase, + totpSecret: nil, + pluginSecureFields: nil + ), + aborted: aborted + ) + } + // MARK: - Discovery private func locations() -> [Location] { diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift index 6fac32f3e..3e48d0621 100644 --- a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift @@ -8,15 +8,16 @@ import Foundation import os import Security -/// Resolves a JetBrains data-source password. The IDE names it with -/// `generateServiceName("DB", uuid)`, which formats as -/// `IntelliJ Platform DB \u{2014} `. On macOS the secret lives -/// in the native Keychain; only the "In KeePass" mode writes the encrypted -/// `c.kdbx`, so the Keychain is tried first and the KDBX is a fallback. +/// Resolves JetBrains credentials. The IDE names each secret with +/// `generateServiceName(subsystem, key)`, formatted +/// `IntelliJ Platform \u{2014} `: +/// - DB password: subsystem `DB`, key ``. +/// - SSH tunnel password: subsystem `SshConfigPassword`, key `: `. +/// - SSH key passphrase: subsystem `SshConfigPassphrase`, same key shape. /// -/// SSH tunnel passwords are not recoverable: DataGrip does not persist them to -/// the Keychain (verified against a saved password-auth tunnel), so the importer -/// brings over the tunnel settings and the user re-enters the password. +/// SSH secrets only exist once a connection authenticated and saved them. On macOS +/// the secret lives in the native Keychain; only the "In KeePass" mode writes the +/// encrypted `c.kdbx`, so the Keychain is tried first and the KDBX is a fallback. final class JetBrainsCredentialStore { enum Lookup { case found(String) @@ -43,10 +44,26 @@ final class JetBrainsCredentialStore { "IntelliJ Platform DB \u{2014} \(uuid)" } + static func sshPasswordServiceName(host: String, port: Int, configId: String) -> String { + "IntelliJ Platform SshConfigPassword \u{2014} \(host):\(port) \(configId)" + } + + static func sshPassphraseServiceName(host: String, port: Int, configId: String) -> String { + "IntelliJ Platform SshConfigPassphrase \u{2014} \(host):\(port) \(configId)" + } + func password(forDataSourceUUID uuid: String) -> Lookup { secret(service: Self.serviceName(forDataSourceUUID: uuid)) } + func sshPassword(host: String, port: Int, configId: String) -> Lookup { + secret(service: Self.sshPasswordServiceName(host: host, port: port, configId: configId)) + } + + func sshKeyPassphrase(host: String, port: Int, configId: String) -> Lookup { + secret(service: Self.sshPassphraseServiceName(host: host, port: port, configId: configId)) + } + private func secret(service: String) -> Lookup { switch readKeychain(service: service) { case .found(let value): return .found(value) diff --git a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift index 301231a36..d8302a997 100644 --- a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -233,6 +233,34 @@ struct DataGripImporterTests { #expect(connection.sshConfig == nil) } + @Test("imports SSH password from c.kdbx end to end") + func sshPasswordImported() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource( + uuid: "1", + extra: "trueSSH1" + ) + ]) + try writeSSHConfigs([ + "" + ]) + + let configDir = optionsDir.deletingLastPathComponent() + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.sshPasswordServiceName(host: "localhost", port: 22, configId: "SSH1") + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "", password: "ssh-pw") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let result = try importer.importConnections(includePasswords: true) + let credentials = try #require(result.envelope.credentials?["0"]) + #expect(credentials.sshPassword == "ssh-pw") + } + @Test("merges user-name from local file when shared file omits it") func usernameFromLocalFile() throws { try writeDataSources([ diff --git a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift index d13dc6d47..10c694dc5 100644 --- a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift +++ b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift @@ -65,4 +65,30 @@ struct JetBrainsCredentialStoreTests { return } } + + @Test("ssh password service name matches DataGrip's keychain format") + func sshPasswordServiceName() { + #expect(JetBrainsCredentialStore.sshPasswordServiceName(host: "localhost", port: 22, configId: "cfg") + == "IntelliJ Platform SshConfigPassword \u{2014} localhost:22 cfg") + #expect(JetBrainsCredentialStore.sshPassphraseServiceName(host: "h", port: 2_222, configId: "cfg") + == "IntelliJ Platform SshConfigPassphrase \u{2014} h:2222 cfg") + } + + @Test("reads ssh password from c.kdbx") + func sshPasswordFromKdbx() throws { + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.sshPasswordServiceName(host: "h", port: 2_222, configId: "cfg") + + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "", password: "ssh-secret") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .found(let password) = store.sshPassword(host: "h", port: 2_222, configId: "cfg") else { + Issue.record("Expected SSH password to be found in KDBX") + return + } + #expect(password == "ssh-secret") + } } diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index f3f98da85..2d608f1e9 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -86,7 +86,7 @@ Groups and folders carry over. | DBeaver | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | Decrypted from config file | | DataGrip | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | From Keychain or `c.kdbx` | -DataGrip stores connections per project. TablePro imports the connections from the projects DataGrip is tracking (its recent projects), along with their SSH tunnel and SSL settings. If a connection is missing, open its project in DataGrip once so it returns to the recent list. DataGrip doesn't store SSH tunnel passwords where TablePro can read them, so re-enter those after importing. Connections protected by a DataGrip master password can't be read. +DataGrip stores connections per project. TablePro imports the connections from the projects DataGrip is tracking (its recent projects), along with their SSH tunnel and SSL settings, and the SSH tunnel password when DataGrip saved one. If a connection is missing, open its project in DataGrip once so it returns to the recent list. Connections protected by a DataGrip master password can't be read. ## Share via Link