diff --git a/CHANGELOG.md b/CHANGELOG.md index 2044a24a9..0dc33ce49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A plus button in the bottom bar of the Tables sidebar opens a menu to create a new table or view, without right-clicking. It's disabled while safe mode blocks writes. - The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139) - A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254) +- Import connections from Navicat: export a connections file from Navicat (File, Export Connections), then pick it under Import from Other App. SSH tunnel and SSL settings come across, and saved passwords are decrypted during import. (#1485) ### Changed diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 9c28d17f7..eff32fd47 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -7,6 +7,7 @@ import AppKit import Foundation import os import Security +import UniformTypeIdentifiers // MARK: - Protocol @@ -22,8 +23,14 @@ protocol ForeignAppImporter { /// system show a per-item access prompt. Importers that read passwords from /// a file (DBeaver, Beekeeper Studio) return false so no prompt is promised. var readsPasswordsFromKeychain: Bool { get } + /// Non-nil for importers that read a user-selected export file instead of an + /// installed app's on-disk store. The values are the content types the file + /// picker filters to; the source picker presents a panel and hands the + /// chosen URL to `setSelectedFile(_:)` before importing. + var importFileTypes: [UTType]? { get } func installedAppURL() -> URL? func connectionCount() -> Int + mutating func setSelectedFile(_ url: URL) func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult } @@ -39,6 +46,10 @@ extension ForeignAppImporter { func isAvailable() -> Bool { installedAppURL() != nil } + + var importFileTypes: [UTType]? { nil } + + mutating func setSelectedFile(_ url: URL) {} } // MARK: - Result @@ -85,7 +96,8 @@ enum ForeignAppImporterRegistry { SequelAceImporter(), DBeaverImporter(), DataGripImporter(), - BeekeeperStudioImporter() + BeekeeperStudioImporter(), + NavicatImporter() ] } diff --git a/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatCipher.swift b/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatCipher.swift new file mode 100644 index 000000000..3c7a39029 --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatCipher.swift @@ -0,0 +1,156 @@ +// +// NavicatCipher.swift +// TablePro +// +// Decrypts the password fields in a Navicat `.ncx` export. Navicat ships two +// fixed-key ciphers (the keys are baked into the app, so no user secret is +// needed): v1 used through Navicat 11 and v2 from Navicat 12 onward. A `.ncx` +// never contains the machine-bound v3 cipher, so trying v2 then v1 and keeping +// the first candidate that decodes to plausible text (valid UTF-8 with no +// control characters) recovers exported passwords. The control-character check +// stops a v1 ciphertext whose length is a multiple of the AES block size from +// being misread as a v2 one. +// Reference: github.com/HyperSine/how-does-navicat-encrypt-password +// +// - v2: AES-128-CBC, PKCS#7 padding, key `libcckeylibcckey`, IV `libcciv libcciv ` +// - v1: Blowfish-ECB blocks wrapped in a custom CBC-style XOR chain, key +// `SHA1("3DC5CA39")`, IV `BlowfishECB(0xFF * 8)` +// Ciphertext is uppercase hex in both schemes. +// + +import CommonCrypto +import Foundation + +enum NavicatCipher { + static func decrypt(_ hex: String) -> String? { + guard !hex.isEmpty, let ciphertext = Data(navicatHex: hex) else { return nil } + if let value = decryptV2(ciphertext), isPlausibleText(value) { return value } + if let value = decryptV1(ciphertext), isPlausibleText(value) { return value } + return nil + } + + private static func isPlausibleText(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + return value.unicodeScalars.allSatisfy { $0.properties.generalCategory != .control } + } + + // MARK: - V2 (Navicat 12+) + + private static let aesKey = Data("libcckeylibcckey".utf8) + private static let aesIV = Data("libcciv libcciv ".utf8) + + private static func decryptV2(_ ciphertext: Data) -> String? { + guard !ciphertext.isEmpty, ciphertext.count.isMultiple(of: kCCBlockSizeAES128) else { return nil } + + let bufferSize = ciphertext.count + kCCBlockSizeAES128 + var buffer = Data(count: bufferSize) + var decryptedSize = 0 + + let status = buffer.withUnsafeMutableBytes { bufferBytes in + ciphertext.withUnsafeBytes { cipherBytes in + aesIV.withUnsafeBytes { ivBytes in + aesKey.withUnsafeBytes { keyBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, kCCKeySizeAES128, + ivBytes.baseAddress, + cipherBytes.baseAddress, ciphertext.count, + bufferBytes.baseAddress, bufferSize, + &decryptedSize + ) + } + } + } + } + guard status == kCCSuccess else { return nil } + return String(data: buffer.prefix(decryptedSize), encoding: .utf8) + } + + // MARK: - V1 (Navicat 11) + + private static let blowfishKey = sha1(Data("3DC5CA39".utf8)) + private static let blockSize = 8 + + private static func decryptV1(_ ciphertext: Data) -> String? { + guard !ciphertext.isEmpty, + var vector = blowfishECB([UInt8](repeating: 0xFF, count: blockSize), operation: kCCEncrypt) else { + return nil + } + + let bytes = [UInt8](ciphertext) + let fullBlocks = bytes.count / blockSize + var output = [UInt8]() + output.reserveCapacity(bytes.count) + + for blockIndex in 0.. 0 { + guard let keystream = blowfishECB(vector, operation: kCCEncrypt) else { return nil } + let tailStart = fullBlocks * blockSize + for offset in 0.. [UInt8]? { + var output = [UInt8](repeating: 0, count: block.count) + var movedBytes = 0 + let status = blowfishKey.withUnsafeBytes { keyBytes in + CCCrypt( + CCOperation(operation), + CCAlgorithm(kCCAlgorithmBlowfish), + CCOptions(kCCOptionECBMode), + keyBytes.baseAddress, blowfishKey.count, + nil, + block, block.count, + &output, output.count, + &movedBytes + ) + } + guard status == kCCSuccess, movedBytes == block.count else { return nil } + return output + } + + private static func sha1(_ data: Data) -> Data { + var hash = Data(count: Int(CC_SHA1_DIGEST_LENGTH)) + hash.withUnsafeMutableBytes { hashBytes in + data.withUnsafeBytes { dataBytes in + _ = CC_SHA1(dataBytes.baseAddress, CC_LONG(data.count), hashBytes.bindMemory(to: UInt8.self).baseAddress) + } + } + return hash + } +} + +private extension Data { + init?(navicatHex hex: String) { + guard hex.count.isMultiple(of: 2) else { return nil } + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. Bool { true } + + mutating func setSelectedFile(_ url: URL) { + ncxFileURL = url + } + + func connectionCount() -> Int { + connectionElements()?.count ?? 0 + } + + func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult { + guard let url = ncxFileURL else { + throw ForeignAppImportError.fileNotFound(displayName) + } + + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw ForeignAppImportError.fileNotFound(displayName) + } + + let document: XMLDocument + do { + document = try XMLDocument(data: data, options: [.nodeLoadExternalEntitiesNever]) + } catch { + throw ForeignAppImportError.parseError(error.localizedDescription) + } + + let elements = (try? document.nodes(forXPath: "//Connection"))?.compactMap { $0 as? XMLElement } ?? [] + guard !elements.isEmpty else { + throw ForeignAppImportError.noConnectionsFound + } + + var connections: [ExportableConnection] = [] + var credentials: [String: ExportableCredentials] = [:] + + for element in elements { + try Task.checkCancellation() + let index = connections.count + connections.append(buildConnection(from: element)) + if includePasswords, let creds = buildCredentials(from: element) { + credentials[String(index)] = creds + } + } + + let envelope = ConnectionExportEnvelope( + formatVersion: 1, + exportedAt: Date(), + appVersion: "Navicat Import", + connections: connections, + groups: nil, + tags: nil, + credentials: credentials.isEmpty ? nil : credentials + ) + return ForeignAppImportResult(envelope: envelope, sourceName: displayName) + } +} + +// MARK: - Parsing + +private extension NavicatImporter { + func connectionElements() -> [XMLElement]? { + guard let url = ncxFileURL, + let data = try? Data(contentsOf: url), + let document = try? XMLDocument(data: data, options: [.nodeLoadExternalEntitiesNever]), + let nodes = try? document.nodes(forXPath: "//Connection") else { + return nil + } + return nodes.compactMap { $0 as? XMLElement } + } + + func buildConnection(from element: XMLElement) -> ExportableConnection { + let type = Self.mapConnType(attr(element, "ConnType")) + let isFileBased = type == "SQLite" + + return ExportableConnection( + name: nonEmpty(attr(element, "ConnectionName")) ?? displayName, + host: isFileBased ? "" : (nonEmpty(attr(element, "Host")) ?? "localhost"), + port: isFileBased ? 0 : (Int(attr(element, "Port")) ?? Self.defaultPort(for: type)), + database: isFileBased ? attr(element, "DatabaseFileName") : attr(element, "Database"), + username: isFileBased ? "" : attr(element, "UserName"), + type: type, + sshConfig: buildSSHConfig(from: element), + sslConfig: buildSSLConfig(from: element), + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + } + + func buildSSHConfig(from element: XMLElement) -> ExportableSSHConfig? { + guard attr(element, "SSH").lowercased() == "true" else { return nil } + let usesKey = attr(element, "SSH_AuthenMethod").uppercased() != "PASSWORD" + let keyPath = usesKey ? ForeignAppPathHelper.resolveKeyPath(attr(element, "SSH_PrivateKey")) : "" + return ExportableSSHConfig( + enabled: true, + host: attr(element, "SSH_Host"), + port: Int(attr(element, "SSH_Port")), + username: attr(element, "SSH_UserName"), + authMethod: usesKey ? "Private Key" : "Password", + privateKeyPath: keyPath, + agentSocketPath: "", + jumpHosts: nil, + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + } + + func buildSSLConfig(from element: XMLElement) -> ExportableSSLConfig? { + guard attr(element, "SSL").lowercased() == "true" else { return nil } + return ExportableSSLConfig( + mode: Self.mapSSLMode(attr(element, "SSL_PGSSLMode")), + caCertificatePath: nonEmpty(attr(element, "SSL_CACert")), + clientCertificatePath: nonEmpty(attr(element, "SSL_ClientCert")), + clientKeyPath: nonEmpty(attr(element, "SSL_ClientKey")) + ) + } + + func buildCredentials(from element: XMLElement) -> ExportableCredentials? { + let password = attr(element, "SavePassword").lowercased() == "true" + ? decryptField(attr(element, "Password")) : nil + let sshPassword = attr(element, "SSH_SavePassword").lowercased() == "true" + ? decryptField(attr(element, "SSH_Password")) : nil + guard password != nil || sshPassword != nil else { return nil } + return ExportableCredentials( + password: password, + sshPassword: sshPassword, + keyPassphrase: nil, + sslClientKeyPassphrase: nil, + totpSecret: nil, + pluginSecureFields: nil + ) + } + + func decryptField(_ hex: String) -> String? { + let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let value = NavicatCipher.decrypt(trimmed), !value.isEmpty else { + Self.logger.warning("Could not decrypt a Navicat password field") + return nil + } + return value + } + + func attr(_ element: XMLElement, _ name: String) -> String { + element.attribute(forName: name)?.stringValue ?? "" + } + + func nonEmpty(_ value: String) -> String? { + value.isEmpty ? nil : value + } +} + +// MARK: - Mapping + +private extension NavicatImporter { + static func mapConnType(_ raw: String) -> String { + switch raw.uppercased() { + case "MYSQL": return "MySQL" + case "MARIADB": return "MariaDB" + case "POSTGRESQL": return "PostgreSQL" + case "ORACLE": return "Oracle" + case "SQLITE": return "SQLite" + case "SQLSERVER": return "SQL Server" + case "MONGODB": return "MongoDB" + default: return raw + } + } + + static func defaultPort(for type: String) -> Int { + switch type { + case "MySQL", "MariaDB": return 3_306 + case "PostgreSQL": return 5_432 + case "Oracle": return 1_521 + case "SQL Server": return 1_433 + case "MongoDB": return 27_017 + default: return 0 + } + } + + static func mapSSLMode(_ raw: String) -> String { + switch raw.uppercased() { + case "VERIFY-CA": return SSLMode.verifyCa.rawValue + case "VERIFY-FULL": return SSLMode.verifyIdentity.rawValue + case "PREFER", "ALLOW": return SSLMode.preferred.rawValue + default: return SSLMode.required.rawValue + } + } +} diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSourcePicker.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSourcePicker.swift index 676f883ae..ebc1779e1 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSourcePicker.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSourcePicker.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct ImportFromAppSourcePicker: View { let onSelect: (any ForeignAppImporter, Bool) -> Void @@ -74,7 +75,11 @@ struct ImportFromAppSourcePicker: View { Text(state.importer.displayName) .font(.body) - if state.available { + if state.importer.importFileTypes != nil { + Text(String(localized: "Choose an export file to import")) + .font(.subheadline) + .foregroundStyle(.secondary) + } else if state.available { Text( state.count == 1 ? String(localized: "1 connection found") @@ -113,7 +118,7 @@ struct ImportFromAppSourcePicker: View { private var passwordToggle: some View { VStack(alignment: .leading, spacing: 2) { Toggle("Include passwords", isOn: $includePasswords) - Text("Read saved passwords from Keychain (requires permission)") + Text(includePasswordsSubtitle) .font(.caption) .foregroundStyle(.secondary) } @@ -139,9 +144,19 @@ struct ImportFromAppSourcePicker: View { // MARK: - Computed + private var selectedImporter: (any ForeignAppImporter)? { + importerStates.first { $0.importer.id == selectedId }?.importer + } + private var isSelectedAvailable: Bool { - guard let selectedId else { return false } - return importerStates.first { $0.importer.id == selectedId }?.available ?? false + importerStates.first { $0.importer.id == selectedId }?.available ?? false + } + + private var includePasswordsSubtitle: String { + if selectedImporter?.readsPasswordsFromKeychain ?? true { + return String(localized: "Read saved passwords from Keychain (requires permission)") + } + return String(localized: "Saved passwords are decrypted during import") } // MARK: - Actions @@ -166,9 +181,33 @@ struct ImportFromAppSourcePicker: View { } private func continueAction() { - guard let selectedId, - let state = importerStates.first(where: { $0.importer.id == selectedId }), + guard let state = importerStates.first(where: { $0.importer.id == selectedId }), state.available else { return } - onSelect(state.importer, includePasswords) + + if state.importer.importFileTypes != nil { + presentFilePicker(for: state.importer) + } else { + onSelect(state.importer, includePasswords) + } + } + + private func presentFilePicker(for importer: any ForeignAppImporter) { + guard let window = NSApp.keyWindow else { return } + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + if let types = importer.importFileTypes { + panel.allowedContentTypes = types + } + panel.message = String(format: String(localized: "Choose a %@ export file to import"), importer.displayName) + + let includePasswords = includePasswords + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + var configured = importer + configured.setSelectedFile(url) + onSelect(configured, includePasswords) + } } } diff --git a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift index cda7cb174..a3c03f100 100644 --- a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift +++ b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("ForeignAppImporterRegistry") @@ -13,13 +13,15 @@ struct ForeignAppImporterRegistryTests { @Test("Registry contains all importers") func testRegistryContainsAllImporters() { let importers = ForeignAppImporterRegistry.all - #expect(importers.count == 4) + #expect(importers.count == 6) let ids = importers.map(\.id) #expect(ids.contains("tableplus")) #expect(ids.contains("sequelace")) #expect(ids.contains("dbeaver")) + #expect(ids.contains("datagrip")) #expect(ids.contains("beekeeperstudio")) + #expect(ids.contains("navicat")) } @Test("All importers have unique IDs") @@ -86,6 +88,15 @@ struct ForeignAppImporterRegistryTests { #expect(importer.appBundleIdentifier == "io.beekeeperstudio.desktop") } + @Test("Navicat importer has correct metadata") + func testNavicatImporterMetadata() { + let importer = NavicatImporter() + #expect(importer.id == "navicat") + #expect(importer.displayName == "Navicat") + #expect(importer.appBundleIdentifier == "com.navicat.NavicatPremium") + #expect(importer.importFileTypes != nil) + } + @Test("Importers declare whether passwords are read from the keychain") func testReadsPasswordsFromKeychainFlags() { #expect(TablePlusImporter().readsPasswordsFromKeychain == true) @@ -93,6 +104,7 @@ struct ForeignAppImporterRegistryTests { #expect(DataGripImporter().readsPasswordsFromKeychain == true) #expect(DBeaverImporter().readsPasswordsFromKeychain == false) #expect(BeekeeperStudioImporter().readsPasswordsFromKeychain == false) + #expect(NavicatImporter().readsPasswordsFromKeychain == false) } @Test("Keychain confirmation applies only to keychain-based importers when importing passwords") diff --git a/TableProTests/Core/Services/ForeignApp/NavicatCipherTests.swift b/TableProTests/Core/Services/ForeignApp/NavicatCipherTests.swift new file mode 100644 index 000000000..b2065821c --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/NavicatCipherTests.swift @@ -0,0 +1,41 @@ +// +// NavicatCipherTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("NavicatCipher") +struct NavicatCipherTests { + @Test("Decrypts a Navicat 12+ (AES) password") + func decryptsV2GoldenVector() { + #expect(NavicatCipher.decrypt("B75D320B6211468D63EB3B67C9E85933") == "This is a test") + } + + @Test("Decrypts a Navicat 11 (Blowfish) password") + func decryptsV1GoldenVector() { + #expect(NavicatCipher.decrypt("0EA71F51DD37BFB60CCBA219BE3A") == "This is a test") + } + + @Test("Decrypts a Navicat 11 password whose length is a multiple of the AES block size") + func decryptsV1PasswordWithBlockAlignedLength() { + #expect(NavicatCipher.decrypt("2E6C8CF471EB0268D3239A0AD531F1B1") == "Sup3rSecret!Pass") + } + + @Test("Returns nil for an empty string") + func returnsNilForEmptyString() { + #expect(NavicatCipher.decrypt("") == nil) + } + + @Test("Returns nil for odd-length hex") + func returnsNilForOddLengthHex() { + #expect(NavicatCipher.decrypt("ABC") == nil) + } + + @Test("Returns nil for non-hex input") + func returnsNilForNonHexInput() { + #expect(NavicatCipher.decrypt("ZZZZ") == nil) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift b/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift new file mode 100644 index 000000000..a6a5d326c --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift @@ -0,0 +1,261 @@ +// +// NavicatImporterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("NavicatImporter", .serialized) +struct NavicatImporterTests { + private var tempDir: URL + private var importer: NavicatImporter + + init() throws { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("NavicatImporterTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + var imp = NavicatImporter() + imp.ncxFileURL = tempDir.appendingPathComponent("connections.ncx") + importer = imp + } + + // MARK: - Fixture Helpers + + private func writeNCX(_ connections: [String]) throws { + guard let url = importer.ncxFileURL else { return } + let body = connections.joined(separator: "\n") + let xml = """ + + + \(body) + + """ + try xml.write(to: url, atomically: true, encoding: .utf8) + } + + private func conn( + name: String = "Test", + type: String = "MYSQL", + host: String = "db.example.com", + port: String = "3306", + user: String = "admin", + database: String = "mydb", + savePassword: String = "false", + password: String = "", + extra: [String: String] = [:] + ) -> String { + var attributes: [String: String] = [ + "ConnectionName": name, + "ConnType": type, + "Host": host, + "Port": port, + "UserName": user, + "Database": database, + "SavePassword": savePassword, + "Password": password + ] + attributes.merge(extra) { _, new in new } + let rendered = attributes + .map { "\($0.key)=\"\(xmlEscape($0.value))\"" } + .joined(separator: " ") + return "" + } + + private func xmlEscape(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } + + // MARK: - Availability + + @Test("isAvailable is true without an installed app") + func isAvailableAlwaysTrue() { + #expect(NavicatImporter().isAvailable() == true) + } + + @Test("Declares an importable file type") + func declaresImportFileTypes() { + #expect(NavicatImporter().importFileTypes != nil) + } + + @Test("readsPasswordsFromKeychain is false") + func readsPasswordsFromKeychainIsFalse() { + #expect(NavicatImporter().readsPasswordsFromKeychain == false) + } + + // MARK: - connectionCount + + @Test("connectionCount is 0 without a file") + func connectionCountZeroWithoutFile() { + #expect(NavicatImporter().connectionCount() == 0) + } + + @Test("connectionCount reflects the file contents") + func connectionCountReflectsFile() throws { + try writeNCX([conn(name: "A"), conn(name: "B")]) + #expect(importer.connectionCount() == 2) + } + + // MARK: - Errors + + @Test("Importing without a file throws") + func importWithoutFileThrows() { + let bare = NavicatImporter() + #expect(throws: ForeignAppImportError.self) { + _ = try bare.importConnections(includePasswords: true) + } + } + + @Test("Malformed XML throws a parse error") + func malformedXMLThrows() throws { + guard let url = importer.ncxFileURL else { return } + try "<< **Import from Other App...** -2. Pick the source app and click **Continue**. +2. Pick the source app and click **Continue**. Navicat opens a file picker: choose the `.ncx` file you exported from Navicat. 3. Review the list, uncheck anything you don't want, then click **Import**. @@ -85,9 +85,12 @@ Groups and folders carry over. | 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` | +| Navicat | MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, MongoDB | Decrypted from `.ncx` file | 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. +Navicat keeps its connections in an encrypted store that can't be read directly, so TablePro imports from an export instead. In Navicat, choose **File** > **Export Connections**, and turn on **Export Password** if you want passwords to come across. Pick the resulting `.ncx` file in TablePro. SSH tunnel and SSL settings carry over. A connection exported without its password imports without one, so you enter it the first time you connect. + ## Share via Link Two link forms ship with TablePro. Pick the one that matches what you want to share.