From 45aeb1a9b0e016fcc573889ec4e49e5b030622e3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 09:47:56 +0700 Subject: [PATCH 1/3] fix(connections): write connection files that iPhone and older Macs can open Every export since 0.80 was format 2, so iPhone build 22 and Macs on 0.79 or earlier refused it as needing a newer version. Write format 1 whenever it reads back as the same bundle, and format 2 only for saved queries, query folders or nested groups. Both import sheets now keep the passphrase step only for a wrong passphrase; any other failure after decryption shows the error screen. --- .../Bundle/ConnectionBundle.swift | 1 + .../Bundle/ConnectionBundleCodec.swift | 10 +- .../Bundle/ConnectionBundleError.swift | 1 + .../Bundle/ConnectionBundleV1.swift | 88 ++++++++++- .../ConnectionBundleCodecTests.swift | 141 +++++++++++++++++- ...tionBundleOldBuildCompatibilityTests.swift | 49 +++++- .../Connection/ConnectionImportSheet.swift | 7 +- .../Views/MobileConnectionImportSheet.swift | 7 +- .../IOSConnectionExportServiceTests.swift | 6 +- .../ConnectionBundleExporterTests.swift | 5 +- .../Services/TeamCatalogPublisherTests.swift | 33 ++++ 11 files changed, 324 insertions(+), 24 deletions(-) diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift index 3587a68cd7..fb8c4e7352 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundle.swift @@ -128,6 +128,7 @@ public struct BundleSavedQuery: Codable, Sendable, Equatable { } public struct ConnectionBundle: Sendable, Equatable { + /// The newest format this build reads. A file is written in it only when format 1 cannot hold the bundle. public static let formatVersion = 2 public let exportedAt: Date diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift index 50ab3f9363..4ec2eeadf0 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleCodec.swift @@ -16,8 +16,10 @@ public enum ConnectionBundleCodec { let json: Data do { json = try await ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase) + } catch ConnectionExportCryptoError.invalidPassphrase { + throw ConnectionBundleError.decryptionFailed(ConnectionExportCryptoError.invalidPassphrase.localizedDescription) } catch { - throw ConnectionBundleError.decryptionFailed(error.localizedDescription) + throw ConnectionBundleError.decodingFailed(error.localizedDescription) } return try decodeJSON(json) } @@ -50,7 +52,7 @@ public enum ConnectionBundleCodec { guard probe.formatVersion <= ConnectionBundle.formatVersion else { throw ConnectionBundleError.unsupportedVersion(probe.formatVersion) } - if probe.formatVersion == 1 { + if probe.formatVersion == ConnectionBundleV1.formatVersion { let envelope = try decodeBody(ConnectionBundleV1.Envelope.self, from: data) return try envelope.upgraded().sanitizedForImport() } @@ -68,11 +70,15 @@ public enum ConnectionBundleCodec { } } + /// Format 2 only when the bundle needs it, so a file without saved queries or nested groups opens in older builds. private static func encodeJSON(_ bundle: ConnectionBundle) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 do { + if let envelope = ConnectionBundleV1.Envelope(bundle) { + return try encoder.encode(envelope) + } return try encoder.encode(bundle) } catch { throw ConnectionBundleError.encodingFailed diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift index 168a0cd8bf..33242805dc 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleError.swift @@ -6,6 +6,7 @@ public enum ConnectionBundleError: LocalizedError, Equatable { case decodingFailed(String) case invalidBundle(String) case requiresPassphrase + /// The passphrase does not open the file. A file that cannot be decrypted for any other reason is `decodingFailed`. case decryptionFailed(String) case encodingFailed case credentialsRequireEncryption diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift index b478788c14..1e5180c97a 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Bundle/ConnectionBundleV1.swift @@ -1,8 +1,11 @@ import Foundation -/// The format 1 file shape, kept frozen: the only place a v1 file is read. +/// The format 1 file shape, kept frozen: the only place a v1 file is read or written. Builds before format 2 +/// read nothing else, so a bundle that fits it is written in it. enum ConnectionBundleV1 { - struct Envelope: Decodable { + static let formatVersion = 1 + + struct Envelope: Codable { let formatVersion: Int let exportedAt: Date let appVersion: String @@ -54,7 +57,7 @@ enum ConnectionBundleV1 { } } - struct Connection: Decodable { + struct Connection: Codable { let settings: ExportableConnection let groupName: String? let tagName: String? @@ -65,6 +68,15 @@ enum ConnectionBundleV1 { case groupName, tagName, tagNames, credentialProfileName } + /// `tagName` repeats the first tag for builds that read only it. + init(settings: ExportableConnection, groupName: String?, tagNames: [String], credentialProfileName: String?) { + self.settings = settings + self.groupName = groupName + self.tagName = tagNames.first + self.tagNames = tagNames.isEmpty ? nil : tagNames + self.credentialProfileName = credentialProfileName + } + init(from decoder: any Decoder) throws { settings = try ExportableConnection(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) @@ -73,20 +85,29 @@ enum ConnectionBundleV1 { tagNames = try container.decodeIfPresent([String].self, forKey: .tagNames) credentialProfileName = try container.decodeIfPresent(String.self, forKey: .credentialProfileName) } + + func encode(to encoder: any Encoder) throws { + try settings.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(groupName, forKey: .groupName) + try container.encodeIfPresent(tagName, forKey: .tagName) + try container.encodeIfPresent(tagNames, forKey: .tagNames) + try container.encodeIfPresent(credentialProfileName, forKey: .credentialProfileName) + } } - struct Group: Decodable { + struct Group: Codable { let name: String let color: String? let iconName: String? } - struct Tag: Decodable { + struct Tag: Codable { let name: String let color: String? } - struct CredentialProfile: Decodable { + struct CredentialProfile: Codable { let name: String let username: String let passwordMode: String @@ -106,3 +127,58 @@ enum ConnectionBundleV1 { return values } } + +extension ConnectionBundleV1.Envelope { + /// Nil unless the envelope reads back as the same bundle, connection `i` as ref "i". Saved queries, query + /// folders, a group inside a group, and names that differ only in case fail that, and so does any field + /// format 2 gains later, so format 1 never drops what format 2 keeps. + init?(_ bundle: ConnectionBundle) { + var credentials: [String: ExportableCredentials] = [:] + for (index, connection) in bundle.connections.enumerated() { + credentials[String(index)] = bundle.credentials[connection.ref] + } + let envelope = Self( + formatVersion: ConnectionBundleV1.formatVersion, + exportedAt: bundle.exportedAt, + appVersion: bundle.appVersion, + connections: bundle.connections.map { connection in + ConnectionBundleV1.Connection( + settings: connection.settings, + groupName: bundle.groupChain(connection.groupRef).last?.name, + tagNames: connection.tagNames, + credentialProfileName: bundle.credentialProfile(connection.credentialProfileRef)?.name + ) + }, + groups: bundle.groups.isEmpty ? nil : bundle.groups.map { + ConnectionBundleV1.Group(name: $0.name, color: $0.color, iconName: $0.iconName) + }, + tags: bundle.tags.isEmpty ? nil : bundle.tags.map { ConnectionBundleV1.Tag(name: $0.name, color: $0.color) }, + credentials: credentials.isEmpty ? nil : credentials, + credentialProfiles: bundle.credentialProfiles.isEmpty ? nil : bundle.credentialProfiles.map { + ConnectionBundleV1.CredentialProfile( + name: $0.name, + username: $0.username, + passwordMode: $0.passwordMode.rawValue, + secureFieldIds: $0.secureFieldIds.isEmpty ? nil : $0.secureFieldIds + ) + } + ) + let renumbered = ConnectionBundle( + unchecked: bundle.exportedAt, + appVersion: bundle.appVersion, + connections: bundle.connections.enumerated().map { index, connection in + var copy = connection + copy.ref = BundleRef(String(index)) + return copy + }, + groups: bundle.groups, + tags: bundle.tags, + credentialProfiles: bundle.credentialProfiles, + credentials: Dictionary(uniqueKeysWithValues: credentials.map { (BundleRef($0.key), $0.value) }), + queryFolders: bundle.queryFolders, + savedQueries: bundle.savedQueries + ) + guard (try? envelope.upgraded()) == renumbered else { return nil } + self = envelope + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift index e53805b03c..aa6148d605 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleCodecTests.swift @@ -37,7 +37,7 @@ struct ConnectionBundleCodecTests { let object = try Self.jsonObject(ConnectionBundleCodec.encode(bundle)) - #expect(object["formatVersion"] as? Int == 2) + #expect(object["formatVersion"] as? Int == 1) #expect(object["exportedAt"] as? String == "2023-11-14T22:13:20Z") #expect(object["appVersion"] as? String == "0.70.0") #expect((object["connections"] as? [Any])?.isEmpty == true) @@ -56,6 +56,89 @@ struct ConnectionBundleCodecTests { #expect(credentials["c1"]?["password"] as? String == "hunter2") } + @Test("A bundle without saved queries or nested groups is written in format 1, linked by name") + func flatBundleIsWrittenInFormatOne() async throws { + let sealed = try await ConnectionBundleCodec.encode(Self.flatBundle(), passphrase: Self.passphrase) + let object = try await Self.jsonObject(ConnectionExportCrypto.decrypt(data: sealed, passphrase: Self.passphrase)) + let connections = try #require(object["connections"] as? [[String: Any]]) + let orders = try #require(connections.first) + + #expect(object["formatVersion"] as? Int == 1) + #expect(orders["name"] as? String == "Orders") + #expect(orders["groupName"] as? String == "Production") + #expect(orders["tagName"] as? String == "prod") + #expect(orders["tagNames"] as? [String] == ["prod", "team"]) + #expect(orders["credentialProfileName"] as? String == "reader") + #expect(connections.allSatisfy { $0["ref"] == nil && $0["groupRef"] == nil && $0["credentialProfileRef"] == nil }) + #expect((object["groups"] as? [[String: Any]])?.first?["ref"] == nil) + #expect((object["credentialProfiles"] as? [[String: Any]])?.first?["passwordMode"] as? String == "pgpass") + let credentials = try #require(object["credentials"] as? [String: Any]) + #expect(Set(credentials.keys) == ["1"]) + } + + @Test("A format 1 file reads back as the bundle it was written from") + func formatOneRoundTrips() async throws { + let bundle = try Self.flatBundle() + + let sealed = try await ConnectionBundleCodec.encode(bundle, passphrase: Self.passphrase) + let decoded = try await ConnectionBundleCodec.decode(sealed, passphrase: Self.passphrase) + + #expect(decoded == bundle) + #expect(decoded.credentials["1"]?.password == "hunter2") + } + + @Test("Saved queries, a query folder or a nested group keep format 2") + func formatTwoOnlyWhenNeeded() throws { + let settings = BundleConnection(ref: "c1", settings: Self.settings()) + let bundles = try [ + ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [settings], + savedQueries: [BundleSavedQuery(ref: "q1", name: "Locks", sql: "SELECT 1")] + ), + ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [settings], + queryFolders: [BundleQueryFolder(ref: "f1", name: "Reports")] + ), + ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [BundleConnection(ref: "c1", settings: Self.settings(), groupRef: "g2")], + groups: [BundleGroup(ref: "g1", name: "Client A"), BundleGroup(ref: "g2", name: "Production", parentRef: "g1")] + ) + ] + + for bundle in bundles { + let data = try ConnectionBundleCodec.encode(bundle) + #expect(try Self.jsonObject(data)["formatVersion"] as? Int == 2) + #expect(try ConnectionBundleCodec.decode(data) == bundle) + } + } + + @Test("Names format 1 would merge keep format 2") + func caseOnlyNamesKeepFormatTwo() throws { + let bundle = try ConnectionBundle( + exportedAt: Self.exportedAt, + appVersion: "0.70.0", + connections: [ + BundleConnection(ref: "c1", settings: Self.settings(), credentialProfileRef: "p1"), + BundleConnection(ref: "c2", settings: Self.settings(name: "Second"), credentialProfileRef: "p2") + ], + credentialProfiles: [ + BundleCredentialProfile(ref: "p1", name: "reader", username: "alice", passwordMode: .prompt), + BundleCredentialProfile(ref: "p2", name: "Reader", username: "bob", passwordMode: .pgpass) + ] + ) + + let data = try ConnectionBundleCodec.encode(bundle) + + #expect(try Self.jsonObject(data)["formatVersion"] as? Int == 2) + #expect(try ConnectionBundleCodec.decode(data) == bundle) + } + @Test("A plain file never carries credentials into the app") func plainDecodeDropsCredentials() throws { let json = Data(""" @@ -123,6 +206,31 @@ struct ConnectionBundleCodecTests { } } + @Test("An encrypted file in a newer format reports the version once the passphrase opens it") + func encryptedNewerFormatIsUnsupported() async throws { + let json = Data(#"{"formatVersion":3,"exportedAt":"2023-11-14T22:13:20Z","appVersion":"9.0","connections":[]}"#.utf8) + let sealed = try await ConnectionExportCrypto.encrypt(data: json, passphrase: Self.passphrase) + + await #expect(throws: ConnectionBundleError.unsupportedVersion(3)) { + _ = try await ConnectionBundleCodec.decode(sealed, passphrase: Self.passphrase) + } + } + + @Test("An encrypted file no passphrase can open fails to parse instead of asking again") + func unreadableContainerIsNotAPassphraseFailure() async { + let saltAndNonce = Data(repeating: 0, count: 44) + let cases: [(container: Data, reason: ConnectionExportCryptoError)] = [ + (Data("TPRO".utf8) + Data([1]) + saltAndNonce + Data(repeating: 0, count: 10), .corruptData), + (Data("TPRO".utf8) + Data([2]) + saltAndNonce + Data(repeating: 0, count: 40), .unsupportedVersion(2)) + ] + for entry in cases { + #expect(ConnectionBundleCodec.isEncrypted(entry.container)) + await #expect(throws: ConnectionBundleError.decodingFailed(entry.reason.localizedDescription)) { + _ = try await ConnectionBundleCodec.decode(entry.container, passphrase: Self.passphrase) + } + } + } + @Test("A newer format is unsupported whatever its shape") func newerFormatIsUnsupported() { let json = Data(#"{"formatVersion":3,"bundle":{"anything":[1,2,3]}}"#.utf8) @@ -308,6 +416,7 @@ struct ConnectionBundleCodecTests { ) } + /// The saved query keeps it in format 2, where credentials are keyed by connection ref. private static func bundleWithCredentials() throws -> ConnectionBundle { try ConnectionBundle( exportedAt: exportedAt, @@ -320,7 +429,8 @@ struct ConnectionBundleCodecTests { sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: ["token": "secret"] - )] + )], + savedQueries: [BundleSavedQuery(ref: "q1", name: "Locks", sql: "SELECT 1", connectionRef: "c1")] ) } @@ -358,6 +468,33 @@ struct ConnectionBundleCodecTests { ) } + /// Built the way a format 1 file is read back: connection `i` has ref "i". + static func flatBundle() throws -> ConnectionBundle { + var builder = ConnectionBundleBuilder(appVersion: "0.70.0", exportedAt: exportedAt) + builder.addConnection( + fullSettings(), + ref: "0", + groupPath: [.init(name: "Production", color: "Blue", iconName: "person.3")], + tags: [BundleTag(name: "prod", color: "Red"), BundleTag(name: "team")], + credentialProfile: .init(name: "reader", username: "ro", passwordMode: .pgpass, secureFieldIds: ["token"]) + ) + builder.addConnection( + settings(name: "Cache"), + ref: "1", + groupPath: [.init(name: "Production")], + credentialProfile: .init(name: "reader", username: "ro", passwordMode: .pgpass), + credentials: ExportableCredentials( + password: "hunter2", + sshPassword: "ssh", + keyPassphrase: nil, + sslClientKeyPassphrase: "ssl", + totpSecret: "totp", + pluginSecureFields: ["token": "secret"] + ) + ) + return try builder.build() + } + static func fullBundle() throws -> ConnectionBundle { try ConnectionBundle( exportedAt: exportedAt, diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift index cd1d50fed7..37ce169cd6 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionBundleOldBuildCompatibilityTests.swift @@ -3,9 +3,43 @@ import TableProImport import Testing /// The frozen copy of the format 1 reader references no live type, so editing a live type cannot make this pass -/// while an older build would fail to decode a new file instead of reporting its version. -@Suite("An older build reports a format 2 file as needing a newer version") +/// while an older build would fail to decode a new file instead of opening it or reporting its version. +@Suite("An older build opens a format 1 file and reports a format 2 file as needing a newer version") struct ConnectionBundleOldBuildCompatibilityTests { + @Test("A file without saved queries or nested groups opens in an older build, linked by name") + func flatFileOpens() throws { + let data = try ConnectionBundleCodec.encode(ConnectionBundleCodecTests.flatBundle().withoutCredentials()) + + let envelope = try OldBuild.decode(data) + + #expect(envelope.formatVersion == 1) + #expect(envelope.appVersion == "0.70.0") + #expect(envelope.connections.map(\.name) == ["Orders", "Cache"]) + #expect(envelope.connections.map(\.groupName) == ["Production", "Production"]) + #expect(envelope.connections.first?.tagName == "prod") + #expect(envelope.connections.first?.tagNames == ["prod", "team"]) + #expect(envelope.connections.map(\.credentialProfileName) == ["reader", "reader"]) + #expect(envelope.connections.first?.sshConfig?.jumpHosts?.first?.host == "jump") + #expect(envelope.groups?.map(\.name) == ["Production"]) + #expect(envelope.groups?.first?.color == "Blue") + #expect(envelope.tags?.map(\.name) == ["prod", "team"]) + #expect(envelope.credentialProfiles?.first?.passwordMode == "pgpass") + #expect(envelope.credentialProfiles?.first?.secureFieldIds == ["token"]) + } + + @Test("An encrypted format 1 file opens in an older build with each password under its connection's position") + func encryptedFlatFileOpens() async throws { + let sealed = try await ConnectionBundleCodec.encode(ConnectionBundleCodecTests.flatBundle(), passphrase: "pw") + + let json = try await ConnectionExportCrypto.decrypt(data: sealed, passphrase: "pw") + let envelope = try OldBuild.decode(json) + + #expect(envelope.formatVersion == 1) + #expect(envelope.credentials?.keys.sorted() == ["1"]) + #expect(envelope.credentials?["1"]?.password == "hunter2") + #expect(envelope.credentials?["1"]?.pluginSecureFields == ["token": "secret"]) + } + @Test("A plain format 2 file decodes with the format 1 types and reads version 2") func plainFileDecodesAsVersionTwo() throws { let data = try ConnectionBundleCodec.encode(ConnectionBundleCodecTests.fullBundle()) @@ -34,13 +68,13 @@ struct ConnectionBundleOldBuildCompatibilityTests { #expect(orders.tunnelCommand?.kubernetesResource == "svc/postgres") } - @Test("A format 2 file with no connections still has every key a format 1 build requires") - func emptyFileDecodes() throws { + @Test("A file with no connections opens in an older build") + func emptyFileOpens() throws { let bundle = try ConnectionBundle(appVersion: "0.70.0", connections: []) - let envelope = try OldBuild.decodeWithoutGate(ConnectionBundleCodec.encode(bundle)) + let envelope = try OldBuild.decode(ConnectionBundleCodec.encode(bundle)) - #expect(envelope.formatVersion == 2) + #expect(envelope.formatVersion == 1) #expect(envelope.connections.isEmpty) } @@ -57,7 +91,8 @@ struct ConnectionBundleOldBuildCompatibilityTests { func encryptedFileDecodesAsVersionTwo() async throws { let bundle = try ConnectionBundle( appVersion: "0.70.0", - connections: [BundleConnection(ref: "c1", settings: ConnectionBundleCodecTests.fullSettings())], + connections: [BundleConnection(ref: "c1", settings: ConnectionBundleCodecTests.fullSettings(), groupRef: "g2")], + groups: [BundleGroup(ref: "g1", name: "Client A"), BundleGroup(ref: "g2", name: "Production", parentRef: "g1")], credentials: ["c1": ExportableCredentials( password: "hunter2", sshPassword: nil, diff --git a/apple/TablePro/Views/Connection/ConnectionImportSheet.swift b/apple/TablePro/Views/Connection/ConnectionImportSheet.swift index 4955884de8..52bc4aceba 100644 --- a/apple/TablePro/Views/Connection/ConnectionImportSheet.swift +++ b/apple/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -148,8 +148,13 @@ struct ConnectionImportSheet: View { passphrase = "" await showReview(of: bundle) } catch { - passphraseError = error.localizedDescription passphrase = "" + // Only a failed decryption can be fixed by another passphrase; a file that decrypts but cannot be read cannot. + guard case .decryptionFailed? = error as? ConnectionBundleError else { + phase = .failed(error.localizedDescription) + return + } + passphraseError = error.localizedDescription } } diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index 54123148cd..829e637ebb 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -261,8 +261,13 @@ struct MobileConnectionImportSheet: View { } catch is CancellationError { return } catch { - passphraseError = error.localizedDescription passphrase = "" + // Only a failed decryption can be fixed by another passphrase; a file that decrypts but cannot be read cannot. + guard case .decryptionFailed? = error as? ConnectionBundleError else { + phase = .failed(error.localizedDescription) + return + } + passphraseError = error.localizedDescription } } diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift index 01d03ecea5..2c436875e1 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -75,12 +75,12 @@ struct IOSConnectionExportServiceTests { #expect(!text.contains(keyMarker)) } - @Test("An export writes the current file format and no saved queries") - func exportWritesCurrentFormatWithoutQueries() async throws { + @Test("An export without nested groups writes the format older builds open, and no saved queries") + func exportWritesFormatOneWithoutQueries() async throws { let text = try await exportedText(includeCredentials: false) let object = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) - #expect(object["formatVersion"] as? Int == ConnectionBundle.formatVersion) + #expect(object["formatVersion"] as? Int == 1) #expect(!object.keys.contains("savedQueries")) #expect(!object.keys.contains("queryFolders")) } diff --git a/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift b/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift index 466b80db22..8229063982 100644 --- a/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift +++ b/apple/TableProTests/Core/Services/Export/ConnectionBundleExporterTests.swift @@ -85,8 +85,9 @@ struct ConnectionBundleExporterTests { #expect(ConnectionBundleCodec.isEncrypted(data)) let bundle = try await ConnectionBundleCodec.decode(data, passphrase: "correct horse") - #expect(bundle.connections.map(\.settings.name) == ["Secret"]) - #expect(bundle.credentials["c1"]?.password == "hunter2") + let connection = try #require(bundle.connections.first) + #expect(connection.settings.name == "Secret") + #expect(bundle.credentials[connection.ref]?.password == "hunter2") } @Test("An encrypted file does not open with the wrong passphrase") diff --git a/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift b/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift index 10bc8b14d1..69e05a8227 100644 --- a/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift +++ b/apple/TableProTests/Core/Services/TeamCatalogPublisherTests.swift @@ -68,6 +68,39 @@ struct TeamCatalogPublisherTests { #expect(!json.contains("hunter2")) } + @Test("A catalog file opens in older builds unless its connection sits in a nested group") + func catalogFileFormatFollowsGroupDepth() throws { + let folder = try makeTempDirectory() + let library = try ImportLibraryFixture() + defer { + try? FileManager.default.removeItem(at: folder) + library.cleanUp() + } + let client = ConnectionGroup(name: "Client A") + let production = ConnectionGroup(name: "Production", parentId: client.id) + for group in [client, production] { + try library.groups.addGroup(group) + } + var flat = DatabaseConnection(name: "Flat DB") + flat.groupId = client.id + var nested = DatabaseConnection(name: "Nested DB") + nested.groupId = production.id + + let written = try TeamCatalogPublisher.publish([flat, nested], to: folder, exporter: library.exporter) + + let objects = try written.map { url in + try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + } + let flatEntry = try #require((objects[0]["connections"] as? [[String: Any]])?.first) + #expect(objects[0]["formatVersion"] as? Int == 1) + #expect(flatEntry["groupName"] as? String == "Client A") + #expect(flatEntry["ref"] == nil) + #expect(objects[1]["formatVersion"] as? Int == 2) + let nestedBundle = try ConnectionBundleCodec.decode(Data(contentsOf: written[1])) + let nestedGroup = nestedBundle.groupChain(nestedBundle.connections.first?.groupRef) + #expect(nestedGroup.map(\.name) == ["Client A", "Production"]) + } + @Test("Published file cannot carry a command password source (RCE guard)") func publishStripsPasswordSource() throws { let folder = try makeTempDirectory() From 3f1df71cf1378f9667759175db7a625fdecdeec1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 09:48:19 +0700 Subject: [PATCH 2/3] docs(changelog): add the fragments for #3685 --- apple/changelog.d/3685.fixed.1.md | 1 + apple/changelog.d/3685.fixed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 apple/changelog.d/3685.fixed.1.md create mode 100644 apple/changelog.d/3685.fixed.md diff --git a/apple/changelog.d/3685.fixed.1.md b/apple/changelog.d/3685.fixed.1.md new file mode 100644 index 0000000000..971903d3ff --- /dev/null +++ b/apple/changelog.d/3685.fixed.1.md @@ -0,0 +1 @@ +An encrypted connection file that needs a newer TablePro reported the error as a wrong passphrase and cleared it. diff --git a/apple/changelog.d/3685.fixed.md b/apple/changelog.d/3685.fixed.md new file mode 100644 index 0000000000..ccf7776e93 --- /dev/null +++ b/apple/changelog.d/3685.fixed.md @@ -0,0 +1 @@ +Connection files without saved queries or nested groups did not open in TablePro for iPhone or in versions before 0.80. From 8e88d852358cfb5c16bd12a66951b2b1cd29823c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 11:45:56 +0700 Subject: [PATCH 3/3] test(ios): find import rows by name, since a format 1 file numbers connections by position --- .../IOSConnectionImportServiceTests.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index 01d07edd48..4011ec5334 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -31,6 +31,11 @@ struct IOSConnectionImportServiceTests { try await analyzed(ConnectionBundleCodec.encode(bundle)) } + // A format 1 file numbers its connections by position, so the refs a test built do not survive the file. + private func ref(of name: String, in preview: ImportPreview) throws -> BundleRef { + try #require(preview.connections.first { $0.settings.name == name }).ref + } + @discardableResult private func apply(_ preview: ImportPreview, _ selection: ImportSelection? = nil) async -> ImportOutcome { let plan = ImportPlanner.plan(preview, selection: selection ?? .defaults(for: preview)) @@ -214,8 +219,9 @@ struct IOSConnectionImportServiceTests { tags: [BundleTag(name: "skipped-tag")] ) let preview = try await analyzed(try builder.build()) + let skipped = try ref(of: "Skipped", in: preview) var selection = ImportSelection.defaults(for: preview) - selection.setSelected(false, connection: "c2", in: preview) + selection.setSelected(false, connection: skipped, in: preview) let outcome = await apply(preview, selection) @@ -264,12 +270,14 @@ struct IOSConnectionImportServiceTests { BundleConnection(ref: "c2", settings: settings(name: "Second")) ]) let preview = try await analyzed(bundle) + let first = try ref(of: "First", in: preview) + let second = try ref(of: "Second", in: preview) var selection = ImportSelection.defaults(for: preview) - selection.setSelected(true, connection: "c1", in: preview) - selection.setSelected(true, connection: "c2", in: preview) + selection.setSelected(true, connection: first, in: preview) + selection.setSelected(true, connection: second, in: preview) - let firstReplace = selection.resolve("c1", as: .replace(existing.id), in: preview) - let secondReplace = selection.resolve("c2", as: .replace(existing.id), in: preview) + let firstReplace = selection.resolve(first, as: .replace(existing.id), in: preview) + let secondReplace = selection.resolve(second, as: .replace(existing.id), in: preview) #expect(firstReplace) #expect(!secondReplace) @@ -309,9 +317,10 @@ struct IOSConnectionImportServiceTests { imported.username = "" let bundle = try ConnectionBundle(appVersion: "Tests", connections: [BundleConnection(ref: "c1", settings: imported)]) let preview = try await analyzed(bundle) + let row = try ref(of: existing.name, in: preview) var selection = ImportSelection.defaults(for: preview) - selection.setSelected(true, connection: "c1", in: preview) - let replaces = selection.resolve("c1", as: .replace(existing.id), in: preview) + selection.setSelected(true, connection: row, in: preview) + let replaces = selection.resolve(row, as: .replace(existing.id), in: preview) #expect(replaces) let outcome = await apply(preview, selection)