From 596ef92aa97b331c2cc5f01ebbb9cd4d00c75bde Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 06:47:28 +0700 Subject: [PATCH 1/3] feat(sync): carry the Mac's SSH route to iPhone and iPad --- apple/CloudKit/production-schema.ckdb | 1 + .../PublishedSSHRoute.swift | 25 ++- .../TableProSync/SyncRecordMapper.swift | 2 + .../ConnectionSyncSchema.swift | 9 +- .../PublishedSSHRouteTests.swift | 12 ++ .../SyncFieldParityTests.swift | 2 +- .../SyncRecordMapperSSHRouteTests.swift | 45 ++++- .../Core/Storage/ConnectionStorage.swift | 6 +- .../SyncCoordinator+PushCollection.swift | 13 +- .../Extensions/SyncCoordinator+Settings.swift | 6 +- .../TablePro/Core/Sync/SyncCoordinator.swift | 12 +- .../TablePro/Core/Sync/SyncRecordMapper.swift | 49 +++-- .../Connection/DatabaseConnection.swift | 2 - .../Helpers/ConnectionLibraryEditing.swift | 2 + .../Services/IOSImportLibraryStore.swift | 2 + .../Sync/IOSSyncCoordinator.swift | 16 +- .../ConnectionLibraryEditingTests.swift | 22 +++ .../IOSConnectionImportServiceTests.swift | 36 ++++ .../Sync/IOSSyncCoordinatorTests.swift | 54 ++++++ .../ConnectionStorageImportTests.swift | 21 ++ .../Core/Sync/SSHRouteSyncRecordTests.swift | 181 +++++++++++++++++- .../Core/Sync/SyncCoordinatorEchoTests.swift | 28 +++ .../SyncRecordMapperConnectionTests.swift | 23 ++- 23 files changed, 519 insertions(+), 50 deletions(-) diff --git a/apple/CloudKit/production-schema.ckdb b/apple/CloudKit/production-schema.ckdb index 25ebadb489..73ec531bdc 100644 --- a/apple/CloudKit/production-schema.ckdb +++ b/apple/CloudKit/production-schema.ckdb @@ -47,6 +47,7 @@ DEFINE SCHEMA sshConfigJson BYTES QUERYABLE SORTABLE, sshEnabled INT64 QUERYABLE SORTABLE, sshProfileId STRING QUERYABLE SEARCHABLE SORTABLE, + sshRouteJson BYTES QUERYABLE SORTABLE, sslConfigJson BYTES QUERYABLE SORTABLE, sslEnabled INT64 QUERYABLE SORTABLE, startupCommands STRING QUERYABLE SEARCHABLE SORTABLE, diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/PublishedSSHRoute.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/PublishedSSHRoute.swift index 8a459ce410..604afa1c68 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/PublishedSSHRoute.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/PublishedSSHRoute.swift @@ -87,8 +87,31 @@ public struct PublishedSSHRoute: Codable, Hashable, Sendable { version == Self.currentVersion && basis == Self.basis(of: input) } + // Aliases, and the hop ids derived from them, are each Mac's own names for the hosts a device dials. public func leadsToSameHosts(as other: PublishedSSHRoute) -> Bool { - version == other.version && basis == other.basis && hops == other.hops && target == other.target + version == other.version && basis == other.basis + && hops.map(Endpoint.init) == other.hops.map(Endpoint.init) + && Endpoint(target) == Endpoint(other.target) + } + + private struct Endpoint: Equatable { + let host: String + let port: Int + let username: String + + init(_ hop: Hop) { + self.init(host: hop.host, port: hop.port, username: hop.username) + } + + init(_ target: Target) { + self.init(host: target.host, port: target.port, username: target.username) + } + + init(host: String, port: Int, username: String) { + self.host = host + self.port = port + self.username = username + } } // True when dialing the input as typed reaches the same hosts, so there is nothing to publish. diff --git a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index e05cfa9c89..0bc37cca24 100644 --- a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -209,6 +209,8 @@ public enum SyncRecordMapper { connection.connectTimeoutSeconds = validConnectTimeout(legacyConnectTimeout) connection.adoptConfigurationSwitches() connection.sshProfileId = (fields[.sshProfileId] as? String).flatMap { UUID(uuidString: $0) } + // Only the Mac writes the route, from its ~/.ssh/config. + connection.sshRoute = (fields[.sshRouteJson] as? Data).flatMap(PublishedSSHRoute.decoded(from:)) return connection } diff --git a/apple/Packages/TableProCore/Sources/TableProSyncTransport/ConnectionSyncSchema.swift b/apple/Packages/TableProCore/Sources/TableProSyncTransport/ConnectionSyncSchema.swift index 28d35207bc..0ad9f9b657 100644 --- a/apple/Packages/TableProCore/Sources/TableProSyncTransport/ConnectionSyncSchema.swift +++ b/apple/Packages/TableProCore/Sources/TableProSyncTransport/ConnectionSyncSchema.swift @@ -29,6 +29,7 @@ public enum ConnectionSyncField: String, SyncSchemaField { case redisDatabase case startupCommands case sshProfileId + case sshRouteJson case isFavorite case modifiedAtLocal case schemaVersion @@ -39,6 +40,12 @@ public enum ConnectionSyncField: String, SyncSchemaField { .isReadOnly, .sshEnabled, .sslEnabled, .queryTimeoutSeconds, .sshConfigJson, .sslConfigJson, .additionalFieldsJson, .aiPolicy, .aiRules, .aiAlwaysAllowedTools, .redisDatabase, .startupCommands, - .sshProfileId, .isFavorite, .modifiedAtLocal, .schemaVersion + .sshProfileId, .sshRouteJson, .isFavorite, .modifiedAtLocal, .schemaVersion ] } + +public extension ConnectionSyncField { + // In each app's readable content, so the first pull on this build fetches everything once and + // reads the routes an older build cached without reading them. + static let sshRouteContentKind = "\(SyncRecordType.connection.rawValue)/\(Self.sshRouteJson.rawValue).v1" +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/PublishedSSHRouteTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/PublishedSSHRouteTests.swift index be054c6ae9..f8c70dcfdf 100644 --- a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/PublishedSSHRouteTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/PublishedSSHRouteTests.swift @@ -112,4 +112,16 @@ struct PublishedSSHRouteTests { second.target.host = "10.0.0.6" #expect(!first.leadsToSameHosts(as: second)) } + + @Test("Two Macs naming the same jump host differently reach the same hosts") + func aliasesDoNotMatter() { + let first = Self.route() + var second = Self.route() + second.hops[0].alias = "jump-host" + second.hops[0].id = PublishedSSHRoute.derivedHopId(alias: "jump-host", occurrence: 0) + second.target.alias = "database" + #expect(first.leadsToSameHosts(as: second)) + second.hops[0].port = 2_222 + #expect(!first.leadsToSameHosts(as: second)) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift index 075e232e07..c37301421b 100644 --- a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncFieldParityTests.swift @@ -42,7 +42,7 @@ struct SyncFieldParityTests { ] // Derived on the Mac from a file only the Mac has, so only the Mac writes it; both apps read it. - private static let connectionMacPublished: Set = [] + private static let connectionMacPublished: Set = ["sshRouteJson"] private static let connectionIosOnly: Set = [ "sshEnabled", "sslEnabled", diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHRouteTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHRouteTests.swift index 7280c69325..fb51f7b740 100644 --- a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHRouteTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSSHRouteTests.swift @@ -33,14 +33,55 @@ struct SyncRecordMapperSSHRouteTests { #expect(pulled.sshProfileId == profileId) } + private static let routeKey = ConnectionSyncField.sshRouteJson.key + + @Test("A pulled record brings the route a Mac dialed, and the tunnel dials it") + func pullReadsRoute() throws { + let connection = tunnelled() + let route = try route(for: connection) + let record = SyncRecordMapper.toRecord(connection, zoneID: zoneID) + record[Self.routeKey] = try #require(route.encoded()) as CKRecordValue + + let pulled = try #require(SyncRecordMapper.toConnection(record)) + + #expect(pulled.sshRoute == route) + #expect(pulled.sshConfigurationForDialing?.host == "10.0.0.5") + #expect(pulled.sshConfigurationForDialing?.jumpHosts.map { $0.host } == ["b.example"]) + } + + @Test("A route this build cannot read is ignored and the connection still arrives") + func unreadableRouteIsIgnored() throws { + let record = SyncRecordMapper.toRecord(tunnelled(), zoneID: zoneID) + record[Self.routeKey] = Data(#"{"version":2,"legs":[]}"#.utf8) as CKRecordValue + + let pulled = try #require(SyncRecordMapper.toConnection(record)) + + #expect(pulled.sshRoute == nil) + #expect(pulled.sshConfigurationForDialing?.host == "db") + } + @Test("This device never writes the route") func iosNeverWritesRoute() throws { var connection = tunnelled() connection.sshRoute = try route(for: connection) let fresh = SyncRecordMapper.toRecord(connection, zoneID: zoneID) - #expect(fresh["sshRouteJson"] == nil) - #expect(!fresh.changedKeys().contains("sshRouteJson")) + #expect(fresh[Self.routeKey] == nil) + #expect(!fresh.changedKeys().contains(Self.routeKey)) + } + + @Test("An edit here leaves the Mac's route on the record") + func editKeepsTheMacRoute() throws { + let base = SyncRecordMapper.toRecord(tunnelled(), zoneID: zoneID) + let routeData = try #require(try route(for: tunnelled()).encoded()) + base[Self.routeKey] = routeData as CKRecordValue + var edited = try #require(SyncRecordMapper.toConnection(base)) + edited.name = "Renamed" + edited.sshRoute = nil + + SyncRecordMapper.updateRecord(base, with: edited) + + #expect(base[Self.routeKey] as? Data == routeData) } @Test("A record built from scratch keeps the SSH profile link this copy knows of") diff --git a/apple/TablePro/Core/Storage/ConnectionStorage.swift b/apple/TablePro/Core/Storage/ConnectionStorage.swift index bf7541215a..fb16b93a61 100644 --- a/apple/TablePro/Core/Storage/ConnectionStorage.swift +++ b/apple/TablePro/Core/Storage/ConnectionStorage.swift @@ -165,8 +165,8 @@ final class ConnectionStorage { return true } - /// A replace keeps the stored row's device-local state, except a credential profile the import - /// created and a tunnel command the user agreed to import. A replace whose row is gone is not written. + // A replace keeps the stored row's device-local state, except a credential profile the import + // created and a tunnel command the user agreed to import. A replace whose row is gone is not written. func applyImport(adding: [DatabaseConnection], replacing: [DatabaseConnection]) -> ConnectionImportWrite? { var connections = loadConnections() guard !lastLoadFailed else { @@ -185,6 +185,8 @@ final class ConnectionStorage { if incoming.isTunnelCommandEnabled { updated.tunnelCommandMode = incoming.tunnelCommandMode } + // A file carries no route, and readers drop the stored one once it no longer describes the tunnel. + updated.sshRoute = stored.sshRoute updated.sortOrder = stored.groupId == updated.groupId ? stored.sortOrder : Self.nextSortOrder(in: connections, groupId: updated.groupId) diff --git a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+PushCollection.swift b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+PushCollection.swift index 68f8863e7c..7e41cb96a5 100644 --- a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+PushCollection.swift +++ b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+PushCollection.swift @@ -63,17 +63,20 @@ extension SyncCoordinator { if boundary.includes(.connection) { let storage = services.connectionStorage let profiles = services.sshProfileStorage + let routePublisher = storage.sshRoutePublisherId await collectRecords(of: .connection, snapshot: snapshot, boundary: boundary, into: &batch, zoneID: zoneID) { let connections = storage.loadConnections() return storage.lastLoadFailed ? nil : connections } disposition: { (connection: DatabaseConnection) in guard connection.participatesInSync else { return .drop } let recordID = SyncRecordMapper.recordID(type: .connection, id: connection.id.uuidString, in: zoneID) - return .push(SyncRecordMapper.toCKRecord( - Self.syncedTunnel(of: connection, profiles: profiles), - in: zoneID, - base: recordCache.record(for: recordID) - )) + let base = recordCache.record(for: recordID) + let synced = SyncRecordMapper.keepingForeignRoute( + of: Self.syncedTunnel(of: connection, profiles: profiles), + in: base, + publisher: routePublisher + ) + return .push(SyncRecordMapper.toCKRecord(synced, in: zoneID, base: base)) } } diff --git a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift index 13f872250d..8a4b5e97dc 100644 --- a/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift +++ b/apple/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift @@ -82,12 +82,16 @@ extension SyncCoordinator { static let formatterContentKind = "\(SyncRecordType.settings.rawValue)/\(AppSettingsCategory.formatter).v1" static var readableContent: Set { - Set(SyncRecordType.verifiedInProduction.map(\.rawValue)).union([Self.themeContentKind, Self.formatterContentKind]) + Set(SyncRecordType.verifiedInProduction.map(\.rawValue)) + .union([Self.themeContentKind, Self.formatterContentKind, ConnectionSyncField.sshRouteContentKind]) } // Only what this Mac applies: a pull skips a category that is off and still moves the token past it. static func readableContent(for settings: SyncSettings) -> Set { var content = Set(SyncRecordType.verifiedInProduction.filter { settings.syncs($0) }.map(\.rawValue)) + if settings.syncs(.connection) { + content.insert(ConnectionSyncField.sshRouteContentKind) + } if settings.syncs(.settings) { content.insert(Self.themeContentKind) content.insert(Self.formatterContentKind) diff --git a/apple/TablePro/Core/Sync/SyncCoordinator.swift b/apple/TablePro/Core/Sync/SyncCoordinator.swift index 3177e1366c..4712fc18ac 100644 --- a/apple/TablePro/Core/Sync/SyncCoordinator.swift +++ b/apple/TablePro/Core/Sync/SyncCoordinator.swift @@ -907,12 +907,20 @@ final class SyncCoordinator: ObservableObject { @discardableResult private func mergeLocalEdits(into remoteRecord: CKRecord, localConnection: DatabaseConnection) -> DatabaseConnection? { guard let base = recordCache.record(for: remoteRecord.recordID) else { return nil } + let zoneID = remoteRecord.recordID.zoneID - let localRecord = SyncRecordMapper.toCKRecord(localConnection, in: remoteRecord.recordID.zoneID) + let local = SyncRecordMapper.keepingForeignRoute( + of: localConnection, + in: base, + publisher: services.connectionStorage.sshRoutePublisherId + ) + let localRecord = SyncRecordMapper.toCKRecord(local, in: zoneID) + // Written again the way this Mac writes, so iOS's spelling of a value this Mac never changed is not an edit. + let baseAsWritten = (try? SyncRecordMapper.toConnection(base)).map { SyncRecordMapper.toCKRecord($0, in: zoneID) } ?? base guard let merged = remoteRecord.copy() as? CKRecord else { return nil } let localFields = localRecord.fields(ConnectionSyncField.self) - let baseFields = base.fields(ConnectionSyncField.self) + let baseFields = baseAsWritten.fields(ConnectionSyncField.self) let mergedFields = merged.fields(ConnectionSyncField.self) for field in ConnectionSyncField.allCases where field != .modifiedAtLocal { guard !CKRecord.isEqualRecordValue(localFields[field], baseFields[field]) else { continue } diff --git a/apple/TablePro/Core/Sync/SyncRecordMapper.swift b/apple/TablePro/Core/Sync/SyncRecordMapper.swift index cb616be215..611018ab67 100644 --- a/apple/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/apple/TablePro/Core/Sync/SyncRecordMapper.swift @@ -32,7 +32,12 @@ enum SyncDecodeError: Error, LocalizedError { /// Pure-function mapper between local models and CKRecord struct SyncRecordMapper { private static let logger = Logger(subsystem: "com.TablePro", category: "SyncRecordMapper") - private static let encoder = JSONEncoder() + // Unsorted keys come out in another order on each encode, and a merge compares fields by their bytes. + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return encoder + }() private static let decoder = JSONDecoder() /// Current schema version stamped on every record @@ -85,11 +90,8 @@ struct SyncRecordMapper { // MARK: - Connection - /// Writes the optional fields whether or not they hold anything, for the reason - /// `toCKRecord(sqlFavorite:in:)` gives: a connection moved out of its group, stripped of its - /// tags or of its AI rules pushed a record that said nothing about them, and the next pull put - /// them back. On the server's own record a nil names the key only where the server still has a - /// value; a record built fresh has nothing to compare against, so it names every empty key. + // Optional fields are written even when empty: under `.changedKeys` a key the record never names + // keeps the server's value, so an emptied group, tag or AI rule came back on the next pull. static func toCKRecord( _ connection: DatabaseConnection, in zone: CKRecordZone.ID, @@ -108,10 +110,7 @@ struct SyncRecordMapper { fields[.color] = connection.color.rawValue fields[.iconName] = LibrarySymbolCatalog.normalizedName(connection.iconName) fields[.safeModeLevel] = connection.preferredSafeModeLevel.rawValue - /// `safeModeLevel` superseded `isReadOnly`, but both are still on the wire and this mapper - /// still reads the old one when the new one is absent. Writing only the new one left the - /// old one holding whatever it last held, so a connection taken out of read-only on a Mac - /// stayed read-only for anything reading the legacy field. + // Older readers still read `isReadOnly` when `safeModeLevel` is absent. fields[.isReadOnly] = Int64(connection.preferredSafeModeLevel == .readOnly ? 1 : 0) fields[.modifiedAtLocal] = Date() fields[.schemaVersion] = schemaVersion @@ -121,7 +120,7 @@ struct SyncRecordMapper { let tagIdStrings = connection.tagIds.map(\.uuidString) fields[.tagIds] = tagIdStrings.isEmpty ? nil : tagIdStrings - /// Cleared with `tagIds`, because a record with no `tagIds` is read through this one. + // Cleared with `tagIds`, because a record with no `tagIds` is read through this one. fields[.tagId] = tagIdStrings.first fields[.groupId] = connection.groupId?.uuidString fields[.aiPolicy] = connection.aiPolicy?.rawValue ?? unrecognizedAIPolicy(in: fields) @@ -132,6 +131,10 @@ struct SyncRecordMapper { fields[.redisDatabase] = connection.redisDatabase.map { Int64($0) } fields[.startupCommands] = connection.startupCommands.flatMap { $0.isEmpty ? nil : $0 } fields[.sshProfileId] = connection.sshProfileId?.uuidString + // A route another build wrote and this one cannot read stays as it is. + if record !== base || connection.sshRoute != Self.sshRoute(in: fields) { + fields[.sshRouteJson] = connection.sshRoute?.encoded() + } // Encode complex structs as JSON Data — contract device-local paths // to portable ~/… form so they resolve correctly on other devices. @@ -189,8 +192,7 @@ struct SyncRecordMapper { let port = (fields[.port] as? Int64).map { Int($0) } ?? 0 let database = fields[.database] as? String ?? "" let username = fields[.username] as? String ?? "" - /// `colorTag` is where iOS wrote a connection's colour as hex before the two platforms - /// converged on this enum, so a colour set on an iPhone shows up here rather than nowhere. + // Older iOS builds wrote the colour as hex to `colorTag`. let colorRaw = fields[.color] as? String ?? fields[.colorTag] as? String ?? ConnectionColor.none.rawValue @@ -283,9 +285,30 @@ struct SyncRecordMapper { if let queryTimeoutSeconds = validQueryTimeout(queryTimeoutSeconds ?? legacyQueryTimeout) { connection.queryTimeoutSeconds = queryTimeoutSeconds } + connection.sshRoute = sshRoute(in: fields) return connection } + private static func sshRoute(in fields: SyncRecordFields) -> PublishedSSHRoute? { + (fields[.sshRouteJson] as? Data).flatMap(PublishedSSHRoute.decoded(from:)) + } + + // Only the Mac that published a route clears it, so a copy cached before this build read the + // field does not wipe the route another Mac published. + static func keepingForeignRoute( + of connection: DatabaseConnection, + in base: CKRecord?, + publisher: UUID + ) -> DatabaseConnection { + guard connection.sshRoute == nil, + let route = base.flatMap({ sshRoute(in: $0.fields(ConnectionSyncField.self)) }), + route.publisher != publisher + else { return connection } + var kept = connection + kept.sshRoute = route + return kept + } + /// A policy a newer release added reads as nil on this Mac. Writing that nil back would clear /// the other device's choice the next time this Mac saves any other change to the connection. private static func unrecognizedAIPolicy(in fields: SyncRecordFields) -> String? { diff --git a/apple/TablePro/Models/Connection/DatabaseConnection.swift b/apple/TablePro/Models/Connection/DatabaseConnection.swift index 5158fa4ce9..06e1c60495 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection.swift @@ -505,8 +505,6 @@ internal extension DatabaseConnection { adopted.cloudSQLProxyMode = local.cloudSQLProxyMode adopted.socksProxyMode = local.socksProxyMode adopted.tunnelCommandMode = local.tunnelCommandMode - // The Connection record has no route field yet, so the route this Mac dialed stays on it. - adopted.sshRoute = local.sshRoute return adopted } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift index 12a9ce5857..3cd1f55059 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionLibraryEditing.swift @@ -163,6 +163,8 @@ nonisolated enum ConnectionLibraryEditing { copy.id = newId copy.name = name copy.isFavorite = false + // Only a Mac writes the route, so the copy's record never carries it and the next pull would drop it. + copy.sshRoute = nil let groupId = effectiveGroupId(of: source, validGroupIds: validGroupIds) let siblings = LibrarySorting.sorted( diff --git a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift index 2cf0961d7a..d63459dcc3 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift @@ -122,6 +122,8 @@ struct IOSImportLibraryStore: ImportLibraryStore { from: stored.sshConfiguration, into: updated.sshConfiguration ) + // A file carries no route, and the stored one is dialed only while it still describes the tunnel. + updated.sshRoute = stored.sshRoute // A Mac SSH profile's password goes to whatever server this connection's tunnel names, so the link // survives only when the file names the same one. if stored.sshProfileId != nil { diff --git a/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index ab0f8995b1..e946eca161 100644 --- a/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -38,9 +38,10 @@ final class IOSSyncCoordinator { private static let logger = Logger(subsystem: "com.TablePro", category: "Sync") + // Plus a kind for each field an older build pulled without reading. static let readableRecordTypes = Set( [SyncRecordType.connection, .group, .tag, .favorite, .favoriteFolder].map(\.rawValue) - ) + ).union([ConnectionSyncField.sshRouteContentKind]) private(set) var status: SyncStatus private(set) var lastSyncDate: Date? @@ -881,7 +882,10 @@ final class IOSSyncCoordinator { local: [DatabaseConnection], remote: PullChanges ) -> (connections: [DatabaseConnection], adoptedFromCache: Set) { - let pulledIds = Set(remote.changedConnections.map(\.id)) + let pulledRoutes = Dictionary( + remote.changedConnections.map { ($0.id, $0.sshRoute) }, + uniquingKeysWith: { _, last in last } + ) var adopted: Set = [] let connections = merge( local: local, @@ -890,9 +894,13 @@ final class IOSSyncCoordinator { dirtyIds: metadata.dirtyIds(for: .connection), tombstonedIds: tombstonedIds(for: .connection) ).map { connection -> DatabaseConnection in - guard !pulledIds.contains(connection.id), let cached = adoptingCachedSSHFields(connection) else { - return connection + // Only a Mac writes the route, so a copy with edits waiting to go up still takes the pulled one. + if let pulledRoute = pulledRoutes[connection.id] { + var routed = connection + routed.sshRoute = pulledRoute + return routed } + guard let cached = adoptingCachedSSHFields(connection) else { return connection } adopted.insert(connection.id) return cached } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift index 8aff9c1ea0..cb02b32eaf 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryEditingTests.swift @@ -2,6 +2,7 @@ import Foundation import TableProConnectionLibrary @testable import TableProMobile import TableProModels +import TableProSSHTransport import Testing @Suite("Connection library editing") @@ -143,6 +144,27 @@ struct ConnectionLibraryEditingTests { #expect(result.change.changedConnectionIds.contains(b.id)) } + @Test("A duplicate starts without the Mac's SSH route, which its record never carries") + func duplicateDropsTheMacRoute() throws { + var source = DatabaseConnection( + name: "Alias", + type: .postgresql, + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "db", username: "") + ) + source.sshRoute = PublishedSSHRoute( + publisher: UUID(), + input: try #require(source.sshConfiguration).publishedRouteInput, + hops: [], + target: .init(alias: "db", host: "10.0.0.5", port: 22, username: "deploy") + ) + + let result = ConnectionLibraryEditing.duplicating(source, named: "Alias Copy", in: [source], validGroupIds: []) + + #expect(result.copy.sshRoute == nil) + #expect(result.copy.sshConfiguration == source.sshConfiguration) + } + @Test("Deleting a group deletes its subgroups and moves all their connections to Ungrouped") func deletingGroupCascades() { let top = ConnectionGroup(name: "Clients", sortOrder: 0) diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index 49786c4cb1..11e668e8bf 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -3,6 +3,7 @@ import TableProConnectionLibrary import TableProDatabase import TableProImport import TableProModels +import TableProSSHTransport import Testing @testable import TableProMobile @@ -498,6 +499,41 @@ struct IOSConnectionImportServiceTests { } } + @Test("Replacing a connection keeps the route a Mac dialed for the same tunnel") + func replaceKeepsTheMacRoute() async throws { + var existing = DatabaseConnection( + name: "Alias", + type: .postgresql, + host: "db.example.com", + port: 5_432, + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy", authMethod: .password) + ) + existing.sshRoute = PublishedSSHRoute( + publisher: UUID(), + input: try #require(existing.sshConfiguration).publishedRouteInput, + hops: [.init(id: UUID(), alias: "edge", host: "edge.example.com", port: 22, username: "jump")], + target: .init(alias: "bastion.example.com", host: "10.0.0.5", port: 22, username: "deploy") + ) + #expect(appState.addConnection(existing)) + var imported = settings(name: existing.name, host: existing.host, ssh: tunnel(authMethod: "password")) + imported.database = "" + 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: row, in: preview) + let resolved = selection.resolve(row, as: .replace(existing.id), in: preview) + #expect(resolved) + + _ = await apply(preview, selection) + + let replaced = try #require(appState.connections.first { $0.id == existing.id }) + #expect(replaced.sshRoute == existing.sshRoute) + #expect(replaced.sshRouteInEffect == existing.sshRoute) + } + // The link makes this device send the Mac profile's password to whatever server the tunnel names. @Test("Replacing a profile-linked connection keeps the link only when the file names the same SSH server") func replaceKeepsTheProfileLinkOnlyForTheSameServer() async throws { diff --git a/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift b/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index 057ebbf86f..a26f3c8767 100644 --- a/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift @@ -2,6 +2,7 @@ import CloudKit import Foundation @testable import TableProMobile import TableProModels +import TableProSSHTransport import TableProSync import TableProSyncTransport import Testing @@ -246,6 +247,59 @@ struct IOSSyncCoordinatorTests { #expect(box.connections.first?.sshProfileId == profileId) } + @Test("A build that reads the Mac's SSH route fetches everything once") + func routeReaderFetchesEverythingOnce() async throws { + let box = LibraryStateBox() + metadata.lastAccountId = "account-a" + var older = IOSSyncCoordinator.readableRecordTypes + older.remove(ConnectionSyncField.sshRouteContentKind) + _ = metadata.adoptReadableRecordTypes(older) + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + let coordinator = makeCoordinator(box: box, transport: FakeSyncTransport(remoteRecords: [], box: box)) + + await coordinator.sync() + #expect(metadata.userDefaults.data(forKey: tokenKey) == nil) + + metadata.userDefaults.set(Data([1, 2, 3]), forKey: tokenKey) + await coordinator.sync() + #expect(metadata.userDefaults.data(forKey: tokenKey) == Data([1, 2, 3])) + } + + @Test("A copy with an edit waiting to go up still takes the route a pull brings, and its push keeps it") + func dirtyCopyTakesThePulledRoute() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection( + name: "Alias", + type: .postgresql, + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "db", username: "") + ) + box.connections = [local] + let route = PublishedSSHRoute( + publisher: UUID(), + input: try #require(local.sshConfiguration).publishedRouteInput, + hops: [.init(id: UUID(), alias: "bastion", host: "b.example", port: 22, username: "jump")], + target: .init(alias: "db", host: "10.0.0.5", port: 22, username: "deploy") + ) + let routeData = try #require(route.encoded()) + let remote = SyncRecordMapper.toRecord(local, zoneID: zoneID) + remote[ConnectionSyncField.sshRouteJson.key] = routeData as CKRecordValue + let transport = FakeSyncTransport(remoteRecords: [remote], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + box.duringPull = { + box.connections[0].name = "Renamed here" + coordinator.markDirty(local.id) + } + + await coordinator.sync() + + let stored = try #require(box.connections.first) + #expect(stored.name == "Renamed here") + #expect(stored.sshRoute == route) + let pushed = try #require(await transport.pushedRecords.first { $0.recordID == remote.recordID }) + #expect(pushed[ConnectionSyncField.sshRouteJson.key] as? Data == routeData) + } + @Test("A cached profile link whose save failed is read again on the next sync") func failedSaveReadsTheCacheAgain() async throws { let box = LibraryStateBox() diff --git a/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift b/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift index 1f457a8cbc..80620c30a2 100644 --- a/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift +++ b/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift @@ -8,6 +8,8 @@ import Foundation import os @testable import TablePro import TableProImport +import TableProSSHConfig +import TableProSSHTransport import TableProSyncTransport import Testing @@ -209,6 +211,25 @@ struct ConnectionStorageImportTests { #expect(replaced.sortOrder == storedOrder) } + @Test("A replace keeps the SSH route this Mac dialed, which a file never carries") + func replaceKeepsTheDialedRoute() throws { + let storage = try makeStorage() + var stored = DatabaseConnection(name: "Alias", sshConfig: SSHConfiguration(enabled: true, host: "db", authMethod: .sshAgent)) + stored.sshRoute = PublishedSSHRoute( + publisher: storage.sshRoutePublisherId, + input: stored.sshConfig.publishedRouteInput, + hops: [], + target: .init(alias: "db", host: "10.0.0.5", port: 22, username: "deploy") + ) + #expect(storage.addConnection(stored)) + + let incoming = DatabaseConnection(id: stored.id, name: "Alias, from the file", sshConfig: stored.sshConfig) + _ = storage.applyImport(adding: [], replacing: [incoming]) + + storage.invalidateCache() + #expect(storage.loadConnection(id: stored.id)?.sshRoute == stored.sshRoute) + } + @Test("A replace that changes group goes to the end of the new group") func replaceIntoAnotherGroupGoesLast() throws { let storage = try makeStorage() diff --git a/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift b/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift index 80b98ff261..c4ddcf553e 100644 --- a/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift +++ b/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift @@ -8,6 +8,7 @@ import Testing struct SSHRouteSyncRecordTests { private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + private let routeKey = ConnectionSyncField.sshRouteJson.key private func route() -> PublishedSSHRoute { PublishedSSHRoute( @@ -18,15 +19,116 @@ struct SSHRouteSyncRecordTests { ) } - // CloudKit refuses a whole record that names a field Production lacks, and the Connection record has no route yet. - @Test("The route stays on this Mac, off the record") - func routeStaysOffTheRecord() { - var connection = DatabaseConnection(name: "Alias") + private func tunnelled() -> DatabaseConnection { + var connection = DatabaseConnection( + name: "Alias", + sshConfig: SSHConfiguration( + enabled: true, + host: "db", + authMethod: .privateKey, + privateKeyPath: "~/.ssh/id_ed25519", + jumpHosts: [SSHJumpHost(host: "edge", authMethod: .privateKey, privateKeyPath: "~/.ssh/edge")] + ) + ) connection.sshRoute = route() + return connection + } + + @Test("The route this Mac dialed goes on the record and comes back") + func routeRoundTrips() throws { + let connection = tunnelled() let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) - #expect(!Set(record.allKeys()).contains("sshRouteJson")) - #expect(ConnectionSyncField(rawValue: "sshRouteJson") == nil) + + #expect(try SyncRecordMapper.toConnection(record).sshRoute == connection.sshRoute) + } + + @Test("The synced route holds hosts, ports and usernames, never a key or a key path") + func routeCarriesNoKeys() throws { + let record = SyncRecordMapper.toCKRecord(tunnelled(), in: zoneID) + let data = try #require(record[routeKey] as? Data) + let route = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let hops = try #require(route["hops"] as? [[String: Any]]) + let target = try #require(route["target"] as? [String: Any]) + + #expect(Set(route.keys) == ["version", "publisher", "basis", "hops", "target"]) + #expect(hops.allSatisfy { Set($0.keys) == ["id", "alias", "host", "port", "username"] }) + #expect(Set(target.keys) == ["alias", "host", "port", "username"]) + #expect(!String(decoding: data, as: UTF8.self).contains(".ssh")) + } + + @Test("A route this Mac cleared is cleared on the server") + func clearedRouteClearsTheServer() { + var connection = tunnelled() + let base = SyncRecordMapper.toCKRecord(connection, in: zoneID) + connection.sshRoute = nil + + let updated = SyncRecordMapper.toCKRecord(connection, in: zoneID, base: base) + + #expect(updated[routeKey] == nil) + #expect(Set(updated.changedKeys()).contains(routeKey)) + } + + @Test("A route this build cannot read survives this Mac saving another change") + func unreadableRouteSurvivesPush() throws { + let base = SyncRecordMapper.toCKRecord(tunnelled(), in: zoneID) + let unreadable = Data(#"{"version":2,"legs":[]}"#.utf8) + base[routeKey] = unreadable as CKRecordValue + var connection = try SyncRecordMapper.toConnection(base) + #expect(connection.sshRoute == nil) + connection.name = "Renamed" + + let updated = SyncRecordMapper.toCKRecord(connection, in: zoneID, base: base) + + #expect(updated[routeKey] as? Data == unreadable) + } + + @Test("An unchanged route keeps the bytes another Mac wrote") + func unchangedRouteIsNotRewritten() throws { + let dialed = tunnelled() + let base = SyncRecordMapper.toCKRecord(dialed, in: zoneID) + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let otherSpelling = try encoder.encode(try #require(dialed.sshRoute)) + base[routeKey] = otherSpelling as CKRecordValue + var connection = try SyncRecordMapper.toConnection(base) + connection.name = "Renamed" + + let updated = SyncRecordMapper.toCKRecord(connection, in: zoneID, base: base) + + #expect(updated[routeKey] as? Data == otherSpelling) + } + + @Test("A copy with no route keeps the one another Mac published, and the publisher clears its own") + func onlyThePublisherClearsTheRoute() throws { + let published = tunnelled() + let route = try #require(published.sshRoute) + let base = SyncRecordMapper.toCKRecord(published, in: zoneID) + var unread = published + unread.sshRoute = nil + + let elsewhere = SyncRecordMapper.keepingForeignRoute(of: unread, in: base, publisher: UUID()) + let kept = SyncRecordMapper.toCKRecord(elsewhere, in: zoneID, base: base) + #expect(elsewhere.sshRoute == route) + #expect(kept[routeKey] as? Data == route.encoded()) + + let publisher = SyncRecordMapper.keepingForeignRoute(of: unread, in: base, publisher: route.publisher) + #expect(publisher.sshRoute == nil) + #expect(SyncRecordMapper.toCKRecord(publisher, in: zoneID, base: base)[routeKey] == nil) + } + + @Test("A Mac upgraded from a build that skipped the route fetches the zone once") + @MainActor + func upgradeRefetchesSkippedRoutes() throws { + let pool = EphemeralDefaultsPool() + let metadata = SyncMetadataStorage(userDefaults: try pool.make().defaults) + var older = SyncCoordinator.readableContent + older.remove(ConnectionSyncField.sshRouteContentKind) + _ = metadata.adoptReadableRecordTypes(older) + + #expect(SyncCoordinator.readableContent(for: .default).contains(ConnectionSyncField.sshRouteContentKind)) + #expect(metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent)) + #expect(!metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent)) } @Test("A profile's emptied port and jump hosts are cleared on the server") @@ -47,3 +149,70 @@ struct SSHRouteSyncRecordTests { #expect(Set(record.changedKeys()).contains(CredentialProfileSyncField.secureFieldIdsJson.key)) } } + +@MainActor +struct SSHRouteSyncCoordinatorTests { + private static let zoneID = SyncTestEnvironment.zoneID + private let environment: SyncTestEnvironment + + init() throws { + environment = try SyncTestEnvironment(label: "ssh-route-sync") + } + + private static func tunnelled() -> DatabaseConnection { + DatabaseConnection(name: "Alias", sshConfig: SSHConfiguration(enabled: true, host: "db", authMethod: .sshAgent)) + } + + // As another Mac published it, for a copy this Mac cached before it read the field. + private func cacheRecord(of connection: DatabaseConnection) throws -> PublishedSSHRoute { + let route = PublishedSSHRoute( + publisher: UUID(), + input: connection.sshConfig.publishedRouteInput, + hops: [.init(id: UUID(), alias: "bastion", host: "b.example", port: 22, username: "jump")], + target: .init(alias: "db", host: "10.0.0.5", port: 22, username: "deploy") + ) + let record = SyncRecordMapper.toCKRecord(connection, in: Self.zoneID) + record[ConnectionSyncField.sshRouteJson.key] = try #require(route.encoded()) as CKRecordValue + environment.recordCache.store([record]) + return route + } + + @Test("An edit from a Mac that cached the record before reading the route leaves another Mac's route on it") + func editKeepsAnotherMacsRoute() async throws { + let connection = Self.tunnelled() + let route = try cacheRecord(of: connection) + #expect(environment.connections.addConnection(connection)) + let transport = ScriptedSyncTransport(zoneID: Self.zoneID) + + #expect(await environment.makeCoordinator(transport: transport).runSyncCycle() == nil) + + let recordID = SyncRecordMapper.recordID(type: .connection, id: connection.id.uuidString, in: Self.zoneID) + let pushed = try #require(await transport.pushedRecords.first { $0.recordID == recordID }) + #expect(pushed[ConnectionSyncField.sshRouteJson.key] as? Data == route.encoded()) + } + + @Test("A copy edited during its push takes another Mac's route from the pull it merges") + func mergeKeepsAnotherMacsRoute() async throws { + let connection = Self.tunnelled() + let route = try cacheRecord(of: connection) + #expect(environment.connections.addConnection(connection)) + var remote = connection + remote.port = 6_543 + remote.sshRoute = route + let remoteRecord = SyncRecordMapper.toCKRecord(remote, in: Self.zoneID) + let store = environment.connections + let connectionId = connection.id + let transport = ScriptedSyncTransport( + zoneID: Self.zoneID, + duringPush: { _ = store.mutateConnections(ids: [connectionId]) { $0.name = "Renamed" } }, + pulled: { _ in PullResult(changedRecords: [remoteRecord], deletedRecordIDs: [], newToken: nil) } + ) + + #expect(await environment.makeCoordinator(transport: transport).runSyncCycle() == nil) + + let merged = try #require(environment.connections.loadConnection(id: connectionId)) + #expect(merged.name == "Renamed") + #expect(merged.port == 6_543) + #expect(merged.sshRoute == route) + } +} diff --git a/apple/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift b/apple/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift index b24170c8e6..84a46a38fb 100644 --- a/apple/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift +++ b/apple/TableProTests/Core/Sync/SyncCoordinatorEchoTests.swift @@ -1,6 +1,7 @@ import CloudKit import Foundation @testable import TablePro +import TableProSSHConfig import TableProSyncTransport import TableProTestSupport import Testing @@ -265,6 +266,33 @@ struct SyncCoordinatorEchoTests { #expect(tracker.dirtyRecords(for: .connection).contains(connectionId.uuidString)) } + @Test("A dirty copy takes another device's SSH change even when that device spelled the JSON differently") + func mergeComparesValuesNotSpelling() async throws { + var connection = TestFixtures.makeConnection(name: "Primary") + connection.sshConfig = SSHConfiguration(enabled: true, host: "bastion.example.com", port: 22, username: "deploy") + let base = SyncRecordMapper.toCKRecord(connection, in: Self.zoneID) + let spelledElsewhere = JSONEncoder() + spelledElsewhere.outputFormatting = .prettyPrinted + base[ConnectionSyncField.sshConfigJson.key] = try spelledElsewhere.encode(connection.sshConfig) as CKRecordValue + recordCache.store([base]) + #expect(connections.addConnection(connection)) + _ = connections.mutateConnections(ids: [connection.id]) { $0.name = "Renamed here" } + var remote = connection + remote.sshConfig.port = 2_222 + let remoteRecord = SyncRecordMapper.toCKRecord(remote, in: Self.zoneID) + let transport = ScriptedSyncTransport( + zoneID: Self.zoneID, + rejecting: [base.recordID], + pulled: { _ in PullResult(changedRecords: [remoteRecord], deletedRecordIDs: [], newToken: nil) } + ) + + _ = await makeCoordinator(transport: transport).runSyncCycle() + + let merged = try #require(connections.loadConnection(id: connection.id)) + #expect(merged.name == "Renamed here") + #expect(merged.sshConfig.port == 2_222) + } + @Test("A connection's own echo keeps this Mac's External Clients level and transport modes") func connectionEchoKeepsDeviceLocalState() async throws { let connection = Self.makeConnectionWithDeviceLocalState(name: "Primary") diff --git a/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift b/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift index cf2337302d..1fb48fcba4 100644 --- a/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift +++ b/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift @@ -31,7 +31,8 @@ struct SyncRecordMapperConnectionTests { "redisDatabase", "startupCommands", "sortOrder", - "isFavorite" + "isFavorite", + "sshRoute" ] private static let rebuiltFromRecord: Set = ["sshTunnelMode"] @@ -46,7 +47,8 @@ struct SyncRecordMapperConnectionTests { "aiRules", "aiAlwaysAllowedTools", "redisDatabase", - "startupCommands" + "startupCommands", + "sshRouteJson" ] private static let keptFromThisMac: Set = [ @@ -58,8 +60,7 @@ struct SyncRecordMapperConnectionTests { "cloudflareTunnelMode", "cloudSQLProxyMode", "socksProxyMode", - "tunnelCommandMode", - "sshRoute" + "tunnelCommandMode" ] private static func storedValues(of connection: DatabaseConnection) -> [String: String] { @@ -91,6 +92,12 @@ struct SyncRecordMapperConnectionTests { connection.additionalFields = ["schema": "public"] connection.connectTimeoutSeconds = 12 connection.queryTimeoutSeconds = 0 + connection.sshRoute = PublishedSSHRoute( + publisher: UUID(), + input: .init(host: "db", port: nil, username: "", jumpHosts: []), + hops: [], + target: .init(alias: "db", host: "10.0.0.5", port: 22, username: "deploy") + ) return connection } @@ -118,12 +125,6 @@ struct SyncRecordMapperConnectionTests { connection.tunnelCommandMode = .inline( TunnelCommandConfiguration(kubernetesNamespace: "data", kubernetesResource: "svc/postgres") ) - connection.sshRoute = PublishedSSHRoute( - publisher: UUID(), - input: .init(host: "db", port: nil, username: "", jumpHosts: []), - hops: [], - target: .init(alias: "db", host: "10.0.0.5", port: 22, username: "deploy") - ) return connection } @@ -245,6 +246,7 @@ struct SyncRecordMapperConnectionTests { #expect(decoded.sortOrder == connection.sortOrder) #expect(decoded.connectTimeoutSeconds == 12) #expect(decoded.queryTimeoutSeconds == 0) + #expect(decoded.sshRoute == connection.sshRoute) } @Test("Timeouts use their deployed wire fields without duplicating query timeout in JSON") @@ -284,6 +286,7 @@ struct SyncRecordMapperConnectionTests { connection.aiAlwaysAllowedTools = [] connection.redisDatabase = nil connection.startupCommands = nil + connection.sshRoute = nil let updated = SyncRecordMapper.toCKRecord(connection, in: zoneID, base: base) From 39bb7ff41c1d4f84f41cf0762ca0a3b30f628093 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 07:10:09 +0700 Subject: [PATCH 2/3] fix(sync): name the route on a fresh record only when this Mac has one --- apple/TablePro/Core/Sync/SyncRecordMapper.swift | 5 +++-- .../Core/Sync/SSHRouteSyncRecordTests.swift | 10 ++++++++++ .../Core/Sync/SyncRecordMapperConnectionTests.swift | 4 +--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apple/TablePro/Core/Sync/SyncRecordMapper.swift b/apple/TablePro/Core/Sync/SyncRecordMapper.swift index 611018ab67..1f454b547b 100644 --- a/apple/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/apple/TablePro/Core/Sync/SyncRecordMapper.swift @@ -131,8 +131,9 @@ struct SyncRecordMapper { fields[.redisDatabase] = connection.redisDatabase.map { Int64($0) } fields[.startupCommands] = connection.startupCommands.flatMap { $0.isEmpty ? nil : $0 } fields[.sshProfileId] = connection.sshProfileId?.uuidString - // A route another build wrote and this one cannot read stays as it is. - if record !== base || connection.sshRoute != Self.sshRoute(in: fields) { + // Only a change is written: a route another build wrote stays, and a record built without the + // server's copy names no route, so a push after the cache was dropped cannot clear another Mac's. + if connection.sshRoute != Self.sshRoute(in: fields) { fields[.sshRouteJson] = connection.sshRoute?.encoded() } diff --git a/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift b/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift index c4ddcf553e..2e55e44ddd 100644 --- a/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift +++ b/apple/TableProTests/Core/Sync/SSHRouteSyncRecordTests.swift @@ -69,6 +69,16 @@ struct SSHRouteSyncRecordTests { #expect(Set(updated.changedKeys()).contains(routeKey)) } + @Test("A record built without the server's copy says nothing about a route this Mac has not got") + func freshRecordLeavesTheRouteAlone() { + var connection = tunnelled() + connection.sshRoute = nil + + let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) + + #expect(!Set(record.changedKeys()).contains(routeKey)) + } + @Test("A route this build cannot read survives this Mac saving another change") func unreadableRouteSurvivesPush() throws { let base = SyncRecordMapper.toCKRecord(tunnelled(), in: zoneID) diff --git a/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift b/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift index 1fb48fcba4..254ccee63b 100644 --- a/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift +++ b/apple/TableProTests/Core/Sync/SyncRecordMapperConnectionTests.swift @@ -47,8 +47,7 @@ struct SyncRecordMapperConnectionTests { "aiRules", "aiAlwaysAllowedTools", "redisDatabase", - "startupCommands", - "sshRouteJson" + "startupCommands" ] private static let keptFromThisMac: Set = [ @@ -286,7 +285,6 @@ struct SyncRecordMapperConnectionTests { connection.aiAlwaysAllowedTools = [] connection.redisDatabase = nil connection.startupCommands = nil - connection.sshRoute = nil let updated = SyncRecordMapper.toCKRecord(connection, in: zoneID, base: base) From 73c457f47e3459ffc0f400d7759edc164df29356 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 07:13:29 +0700 Subject: [PATCH 3/3] docs(changelog): add the route sync fragment --- apple/changelog.d/3769.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3769.added.md diff --git a/apple/changelog.d/3769.added.md b/apple/changelog.d/3769.added.md new file mode 100644 index 0000000000..f26ef8a1ca --- /dev/null +++ b/apple/changelog.d/3769.added.md @@ -0,0 +1 @@ +ProxyJump and Host entries from the Mac's ~/.ssh/config now reach iPhone and iPad.