diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index fb089f2b..89060a9b 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -227,6 +227,14 @@ TargetAttributes = { TRMD00000000000000000050 = { CreatedOnToolsVersion = 26.6; + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + com.apple.iCloud = { + enabled = 1; + }; + }; }; TRMD00000000000000020050 = { CreatedOnToolsVersion = 26.6; @@ -388,6 +396,7 @@ TRMD000000000000000000D0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APS_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Termind/Termind.entitlements; @@ -401,6 +410,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; + ICLOUD_CONTAINER_ENVIRONMENT = Development; INFOPLIST_FILE = Termind/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -417,6 +427,7 @@ TRMD000000000000000000E0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APS_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Termind/Termind.entitlements; @@ -430,6 +441,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; + ICLOUD_CONTAINER_ENVIRONMENT = Production; INFOPLIST_FILE = Termind/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Termind/App/AppDependencies.swift b/Termind/App/AppDependencies.swift index a152d196..c4527a69 100644 --- a/Termind/App/AppDependencies.swift +++ b/Termind/App/AppDependencies.swift @@ -6,16 +6,19 @@ struct AppDependencies { let outboxRunner: OutboxRunner? let connectionCoordinator: RemoteConnectionCoordinator let agentRunRepository: (any AgentRunPersisting)? + let vaultSync: VaultSyncCoordinator? init( hostRepository: any HostRepository, outboxRunner: OutboxRunner?, connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator(), - agentRunRepository: (any AgentRunPersisting)? = nil + agentRunRepository: (any AgentRunPersisting)? = nil, + vaultSync: VaultSyncCoordinator? = nil ) { self.hostRepository = hostRepository self.outboxRunner = outboxRunner self.connectionCoordinator = connectionCoordinator self.agentRunRepository = agentRunRepository + self.vaultSync = vaultSync } static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self { diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index a16d1837..5820e423 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -61,6 +61,7 @@ final class AppModel { let commands = AppCommandRegistry() let connections: RemoteConnectionCoordinator let hostRepository: any HostRepository + let vaultSync: VaultSyncCoordinator? let forwarding: PortForwardingController let sftpTransfers: SFTPTransferCoordinator /// Providers, models, role assignments, MCP servers. @@ -125,6 +126,7 @@ final class AppModel { openSFTP: { try await connectionCoordinator.connectSFTP(using: $0) } ) hostRepository = dependencies.hostRepository + vaultSync = dependencies.vaultSync forwarding = PortForwardingController(hosts: hosts) sftpTransfers = SFTPTransferCoordinator( store: hosts, @@ -163,6 +165,14 @@ final class AppModel { hosts.didPersistCore = { [weak repository = dependencies.hostRepository] in try? repository?.refresh() } + hosts.didRecordVaultChanges = { [weak vaultSync = dependencies.vaultSync] mutations in + vaultSync?.noteLocalChanges(mutations) + } + if let repository = dependencies.hostRepository as? SwiftDataHostRepository { + repository.didRecordVaultChanges = { [weak vaultSync = dependencies.vaultSync] mutations in + vaultSync?.noteLocalChanges(mutations) + } + } if let outboxRunner { outboxRunner.register( RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.closeWorkspaces) { @@ -196,6 +206,40 @@ final class AppModel { ) outboxRunner.start() } + vaultSync?.didApplyRemote = { [weak self] snapshot, keys in + self?.applyRemoteVaultSnapshot(snapshot, changedKeys: keys) + } + vaultSync?.start() + } + + private func applyRemoteVaultSnapshot( + _ snapshot: VaultSnapshot, changedKeys: Set + ) { + let changedHostIDs = Set(changedKeys.filter { $0.kind == .host }.map(\.id)) + let changedRuleIDs = Set(changedKeys.filter { $0.kind == .portForwarding }.map(\.id)) + let nextHostIDs = Set(snapshot.hosts.map(\.id)) + + for hostID in changedHostIDs { + let oldHost = hosts.host(hostID) + let newHost = snapshot.hosts.first { $0.id == hostID } + let routeChanged = oldHost.map { old in + newHost.map { + old.host != $0.host || old.port != $0.port + || old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID + || old.proxy != $0.proxy + } ?? true + } ?? false + if routeChanged { + hosts.willDeleteHost?(hostID, hosts.portForwardings(for: hostID).map(\.id)) + } + if oldHost != nil, !nextHostIDs.contains(hostID) { + closeWorkspaces(for: hostID) + sftpTransfers.cancelAll(for: hostID) + commandHistory.clear(for: hostID) + } + } + if !changedRuleIDs.isEmpty { forwarding.stop(Array(changedRuleIDs)) } + hosts.replacePersistedSnapshot(snapshot) } func sendAgentDraft( diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index 3e2d491a..7f72798b 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -6,6 +6,8 @@ import Foundation /// into two preferences that disagree. Everything user-facing that lives in /// `UserDefaults` rather than SwiftData is named here. nonisolated enum SettingsKey { + static let vaultSyncEnabled = "vault.sync.enabled" + static let defaultVaultSyncEnabled = true static let mcpServerEnabled = "mcpServer.enabled" static let mcpServerPort = "mcpServer.port" static let mcpServerDisabledTools = "mcpServer.disabledTools" diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index 05b79e78..0d711567 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -14,7 +14,13 @@ struct TermindApp: App { init() { do { - let configuration = ModelConfiguration("Termind", schema: HostPersistence.schema) + let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] + != nil + let configuration = ModelConfiguration( + "Termind", schema: HostPersistence.schema, + isStoredInMemoryOnly: isRunningTests, + cloudKitDatabase: .none + ) let container = try ModelContainer( for: HostPersistence.schema, migrationPlan: TermindMigrationPlan.self, @@ -28,6 +34,9 @@ struct TermindApp: App { let repository = SwiftDataHostRepository(context: container.mainContext) let outboxStore = SwiftDataOutboxStore(context: container.mainContext) let outboxRunner = OutboxRunner(store: outboxStore) + let vaultSync: VaultSyncCoordinator? = isRunningTests + ? nil + : VaultSyncCoordinator(context: container.mainContext) let externalToolCalls = ToolAuditModel( modelContext: container.mainContext, hostName: { [weak hosts] id in hosts?.host(id)?.name } @@ -45,7 +54,8 @@ struct TermindApp: App { outboxRunner: outboxRunner, agentRunRepository: SwiftDataAgentRunRepository( context: container.mainContext - ) + ), + vaultSync: vaultSync ) ) ) diff --git a/Termind/Application/FeatureModels/HostLibraryModel.swift b/Termind/Application/FeatureModels/HostLibraryModel.swift index 77fca5c2..c098d685 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel.swift @@ -26,6 +26,7 @@ final class HostLibraryModel { var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)? var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)? var didPersistCore: (() -> Void)? + var didRecordVaultChanges: ((VaultMutationSet) -> Void)? func reportStorageError(_ error: any Error) { storageError = error.localizedDescription @@ -1738,19 +1739,43 @@ final class HostLibraryModel { return true } do { - try persistence.save(VaultSnapshot( + let mutations = try persistence.save(VaultSnapshot( hosts: hosts, groups: groups, identities: identities, keys: keys, knownHosts: knownHosts, snippets: snippets, snippetPackages: snippetPackages, portForwardings: portForwardings )) storageError = nil didPersistCore?() + didRecordVaultChanges?(mutations) return true } catch { storageError = error.localizedDescription return false } } + + /// Applies a CloudKit-imported projection without writing it back as a new + /// local edit. Runtime connection and transfer state intentionally remain. + func replacePersistedSnapshot(_ snapshot: VaultSnapshot) { + hosts = snapshot.hosts + groups = snapshot.groups + identities = normalizedManualOrder( + snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name + ) + keys = normalizedManualOrder( + snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label + ) + knownHosts = snapshot.knownHosts + snippets = normalizedSnippetOrder(snapshot.snippets) + snippetPackages = snapshot.snippetPackages + portForwardings = snapshot.portForwardings + statuses = statuses.filter { id, _ in hosts.contains { $0.id == id } } + connectionStatuses = connectionStatuses.filter { _, value in + hosts.contains { $0.id == value.hostID } + } + storageError = nil + didPersistCore?() + } } extension Host { diff --git a/Termind/Application/Repositories/VaultPersistence.swift b/Termind/Application/Repositories/VaultPersistence.swift index f1a094d4..2d599e33 100644 --- a/Termind/Application/Repositories/VaultPersistence.swift +++ b/Termind/Application/Repositories/VaultPersistence.swift @@ -11,9 +11,15 @@ nonisolated struct VaultSnapshot: Sendable { var portForwardings: [PortForwardingRule] = [] } +nonisolated struct VaultRemoteApplyResult: Sendable { + var snapshot: VaultSnapshot + var acceptedKeys: Set + var needsUpload: Set +} + @MainActor protocol VaultPersisting: AnyObject { func load() throws -> VaultSnapshot - func save(_ snapshot: VaultSnapshot) throws + func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult } - diff --git a/Termind/Domain/Connections/PortForwardingRule.swift b/Termind/Domain/Connections/PortForwardingRule.swift index d1ce079d..c4c0e265 100644 --- a/Termind/Domain/Connections/PortForwardingRule.swift +++ b/Termind/Domain/Connections/PortForwardingRule.swift @@ -1,6 +1,6 @@ import Foundation -nonisolated struct PortForwardingRule: Identifiable, Hashable, Sendable { +nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, Sendable { nonisolated enum Kind: String, Codable, Sendable, CaseIterable, Identifiable { case local, remote, dynamic nonisolated var id: String { rawValue } @@ -66,4 +66,3 @@ nonisolated struct PortForwardingRule: Identifiable, Hashable, Sendable { } typealias PortForwardingKind = PortForwardingRule.Kind - diff --git a/Termind/Domain/Credentials/Identity.swift b/Termind/Domain/Credentials/Identity.swift index cf86d84c..83c6afd0 100644 --- a/Termind/Domain/Credentials/Identity.swift +++ b/Termind/Domain/Credentials/Identity.swift @@ -65,7 +65,7 @@ nonisolated enum SecretAvailability: Equatable, Sendable { /// "the password is filed under this name" — and `keyID` points at an `SSHKeyRef`, /// which is itself only a reference. Neither the key nor the password has a path /// into this struct, so neither has a path into SwiftData or CloudKit. -nonisolated struct Identity: Identifiable, Hashable, Sendable { +nonisolated struct Identity: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var name: String var username: String diff --git a/Termind/Domain/Credentials/SSHKey.swift b/Termind/Domain/Credentials/SSHKey.swift index 71248020..0841f66d 100644 --- a/Termind/Domain/Credentials/SSHKey.swift +++ b/Termind/Domain/Credentials/SSHKey.swift @@ -26,7 +26,7 @@ nonisolated enum SSHKeyAlgorithm: String, Codable, Hashable, Sendable, CaseItera nonisolated var id: Self { self } } -nonisolated struct SSHKeyRef: Identifiable, Hashable, Sendable { +nonisolated struct SSHKeyRef: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var label: String var algorithm: SSHKeyAlgorithm = .ed25519 diff --git a/Termind/Domain/Hosts/Host.swift b/Termind/Domain/Hosts/Host.swift index 104586ee..1e31a150 100644 --- a/Termind/Domain/Hosts/Host.swift +++ b/Termind/Domain/Hosts/Host.swift @@ -52,7 +52,7 @@ nonisolated struct HostProxy: Codable, Hashable, Sendable { /// name and the secret both belong to `Identity`, which is shared across a fleet. /// So there is no `username` and no `credentialRef` here, only `identityID`, and /// therefore no path by which a secret could reach SwiftData or CloudKit. -nonisolated struct Host: Identifiable, Hashable, Sendable { +nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var name: String /// Where to connect: an IP literal (v4 or v6) or a DNS name. Named `host` @@ -124,7 +124,7 @@ nonisolated struct Host: Identifiable, Hashable, Sendable { /// A user-defined folder of hosts. `parentID` is optional and self-referential, /// which is also what CloudKit requires of the eventual relationship. -nonisolated struct HostGroup: Identifiable, Hashable, Sendable { +nonisolated struct HostGroup: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var name: String var parentID: HostGroup.ID? diff --git a/Termind/Domain/Hosts/KnownHost.swift b/Termind/Domain/Hosts/KnownHost.swift index ff10b00a..2df09dc9 100644 --- a/Termind/Domain/Hosts/KnownHost.swift +++ b/Termind/Domain/Hosts/KnownHost.swift @@ -6,7 +6,7 @@ import Foundation /// a fingerprint pinned on one machine should be pinned on all of them, so a /// swapped key is caught the first time any device sees it rather than the third. /// Nothing secret lives here; a public key fingerprint is public by construction. -nonisolated struct KnownHostEntry: Identifiable, Hashable, Sendable { +nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var hostname: String var port: Int = 22 diff --git a/Termind/Domain/Snippets/Snippet.swift b/Termind/Domain/Snippets/Snippet.swift index a7767cd2..10c0ec0b 100644 --- a/Termind/Domain/Snippets/Snippet.swift +++ b/Termind/Domain/Snippets/Snippet.swift @@ -2,7 +2,7 @@ import Foundation /// A folder used only to organise snippets. Execution settings deliberately /// stay on the snippet, so moving a card never changes where it runs. -nonisolated struct SnippetPackage: Identifiable, Hashable, Sendable { +nonisolated struct SnippetPackage: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var name: String var parentID: SnippetPackage.ID? @@ -11,7 +11,7 @@ nonisolated struct SnippetPackage: Identifiable, Hashable, Sendable { } /// A reusable multi-line script with the hosts it normally opens and runs on. -nonisolated struct Snippet: Identifiable, Hashable, Sendable { +nonisolated struct Snippet: Identifiable, Codable, Hashable, Sendable { var id: UUID = UUID() var title: String var body: String diff --git a/Termind/Domain/Vault/VaultSyncTypes.swift b/Termind/Domain/Vault/VaultSyncTypes.swift new file mode 100644 index 00000000..6998ce4b --- /dev/null +++ b/Termind/Domain/Vault/VaultSyncTypes.swift @@ -0,0 +1,131 @@ +import Foundation + +nonisolated enum VaultRecordKind: String, Codable, CaseIterable, Sendable { + case host + case hostGroup + case identity + case sshKey + case knownHost + case snippet + case snippetPackage + case portForwarding +} + +nonisolated struct VaultRecordKey: Codable, Hashable, Sendable { + var kind: VaultRecordKind + var id: UUID + + var recordName: String { "v1/\(kind.rawValue)/\(id.uuidString.lowercased())" } + + init(kind: VaultRecordKind, id: UUID) { + self.kind = kind + self.id = id + } + + init?(recordName: String) { + let components = recordName.split(separator: "/", omittingEmptySubsequences: false) + guard components.count == 3, components[0] == "v1", + let kind = VaultRecordKind(rawValue: String(components[1])), + let id = UUID(uuidString: String(components[2])) + else { return nil } + self.init(kind: kind, id: id) + } +} + +/// A hybrid timestamp. `counter` orders multiple writes when wall clocks do not +/// advance, and `writerID` makes ties deterministic across installations. +nonisolated struct VaultVersion: Codable, Hashable, Sendable, Comparable { + var modifiedAt: Date + var counter: UInt64 + var writerID: UUID + + static let baselineDate = Date(timeIntervalSince1970: 0) + + static func baseline(writerID: UUID) -> Self { + Self(modifiedAt: baselineDate, counter: 0, writerID: writerID) + } + + static func next(after previous: Self?, writerID: UUID, now: Date = .now) -> Self { + guard let previous else { return Self(modifiedAt: now, counter: 0, writerID: writerID) } + if now > previous.modifiedAt { + return Self(modifiedAt: now, counter: 0, writerID: writerID) + } + return Self( + modifiedAt: previous.modifiedAt, + counter: previous.counter &+ 1, + writerID: writerID + ) + } + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.modifiedAt != rhs.modifiedAt { return lhs.modifiedAt < rhs.modifiedAt } + if lhs.counter != rhs.counter { return lhs.counter < rhs.counter } + return lhs.writerID.uuidString < rhs.writerID.uuidString + } +} + +nonisolated enum VaultRecordPayload: Codable, Hashable, Sendable { + case host(Host) + case hostGroup(HostGroup) + case identity(Identity) + case sshKey(SSHKeyRef) + case knownHost(KnownHostEntry) + case snippet(Snippet) + case snippetPackage(SnippetPackage) + case portForwarding(PortForwardingRule) + + var key: VaultRecordKey { + switch self { + case .host(let value): VaultRecordKey(kind: .host, id: value.id) + case .hostGroup(let value): VaultRecordKey(kind: .hostGroup, id: value.id) + case .identity(let value): VaultRecordKey(kind: .identity, id: value.id) + case .sshKey(let value): VaultRecordKey(kind: .sshKey, id: value.id) + case .knownHost(let value): VaultRecordKey(kind: .knownHost, id: value.id) + case .snippet(let value): VaultRecordKey(kind: .snippet, id: value.id) + case .snippetPackage(let value): VaultRecordKey(kind: .snippetPackage, id: value.id) + case .portForwarding(let value): VaultRecordKey(kind: .portForwarding, id: value.id) + } + } +} + +nonisolated struct VaultEnvelope: Codable, Hashable, Sendable { + static let currentFormatVersion = 1 + + var formatVersion = Self.currentFormatVersion + var key: VaultRecordKey + var version: VaultVersion + var isDeleted: Bool + var payload: VaultRecordPayload? + + init( + key: VaultRecordKey, version: VaultVersion, + isDeleted: Bool = false, payload: VaultRecordPayload? = nil + ) { + self.key = key + self.version = version + self.isDeleted = isDeleted + self.payload = payload + } + + var isValid: Bool { + formatVersion == Self.currentFormatVersion + && (isDeleted ? payload == nil : payload?.key == key) + } +} + +nonisolated struct VaultMutationSet: Equatable, Sendable { + var keys: Set = [] + + static let empty = Self() + var isEmpty: Bool { keys.isEmpty } +} + +nonisolated enum VaultSyncStatus: Equatable, Sendable { + case checkingAccount + case paused + case unavailable(String) + case accountChangeConfirmationRequired + case syncing(pendingCount: Int) + case upToDate(lastSuccessfulSync: Date?) + case failed(String) +} diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index cff9a27f..7f95b33a 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -487,11 +487,63 @@ private struct TerminalSettings: View { } private struct SyncSettings: View { + @Environment(AppModel.self) private var app + @AppStorage(SettingsKey.vaultSyncEnabled) private var isEnabled = + SettingsKey.defaultVaultSyncEnabled + var body: some View { Section("iCloud") { - LabeledContent("Status") { Text("Off") } - Text("iCloud sync is not available yet. Private keys and passwords remain in the Keychain.") + Toggle("Sync Vault with iCloud", isOn: $isEnabled) + LabeledContent("Status") { statusLabel } + + if case .accountChangeConfirmationRequired = status { + Text("This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge.") + .font(.caption).foregroundStyle(.secondary) + Button("Merge with This iCloud Account") { + app.vaultSync?.confirmAccountSwitchMerge() + } + } else if case .failed = status { + Button("Retry") { app.vaultSync?.retry() } + } + + Text("Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs.") .font(.caption).foregroundStyle(.secondary) } + .onChange(of: isEnabled) { _, enabled in app.vaultSync?.setEnabled(enabled) } + } + + private var status: VaultSyncStatus { + app.vaultSync?.status ?? (isEnabled ? .checkingAccount : .paused) + } + + @ViewBuilder + private var statusLabel: some View { + switch status { + case .checkingAccount: + Label("Checking iCloud…", systemImage: "icloud") + case .paused: + Label("Paused", systemImage: "pause.circle") + case .unavailable(let message): + Label(message, systemImage: "icloud.slash").foregroundStyle(.secondary) + case .accountChangeConfirmationRequired: + Label("Waiting for confirmation", systemImage: "person.crop.circle.badge.exclamationmark") + .foregroundStyle(.orange) + case .syncing(let pendingCount): + Label( + pendingCount == 1 ? "Syncing 1 change…" : "Syncing \(pendingCount) changes…", + systemImage: "arrow.triangle.2.circlepath.icloud" + ) + case .upToDate(let lastSync): + if let lastSync { + Label( + "Up to date · \(lastSync.formatted(date: .abbreviated, time: .shortened))", + systemImage: "checkmark.icloud" + ) + } else { + Label("Up to date", systemImage: "checkmark.icloud") + } + case .failed(let message): + Label(message, systemImage: "exclamationmark.icloud").foregroundStyle(.red) + } } } diff --git a/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift b/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift new file mode 100644 index 00000000..f065c1db --- /dev/null +++ b/Termind/Infrastructure/Cloud/VaultSyncCoordinator.swift @@ -0,0 +1,503 @@ +import CloudKit +import CryptoKit +import Foundation +import SwiftData + +@MainActor +@Observable +final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable { + nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind" + nonisolated static let zoneName = "TermindVaultV1" + nonisolated static let recordType = "VaultItem" + nonisolated static let subscriptionID = "termind-vault-v1-changes" + nonisolated static let inlinePayloadLimit = 700_000 + + private let context: ModelContext + private let persistence: HostPersistence + private let journal: VaultSyncJournal + private let container: CKContainer + private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + private var engine: CKSyncEngine? + private var operation: Task? + private var isStarted = false + + private(set) var status: VaultSyncStatus = .checkingAccount + var didApplyRemote: ((VaultSnapshot, Set) -> Void)? + + var isEnabled: Bool { + UserDefaults.standard.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool + ?? SettingsKey.defaultVaultSyncEnabled + } + + init( + context: ModelContext, + container: CKContainer = CKContainer(identifier: VaultSyncCoordinator.containerIdentifier) + ) { + self.context = context + persistence = HostPersistence(context: context) + journal = VaultSyncJournal(context: context) + self.container = container + super.init() + } + + func start() { + guard isEnabled, !isStarted else { + if !isEnabled { status = .paused } + return + } + isStarted = true + run { [weak self] in try await self?.prepareAndSynchronize() } + } + + func setEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: SettingsKey.vaultSyncEnabled) + if enabled { start() } + else { pause() } + } + + func pause() { + operation?.cancel() + operation = nil + isStarted = false + let engine = self.engine + self.engine = nil + status = .paused + if let engine { Task { await engine.cancelOperations() } } + } + + func retry() { + guard isEnabled else { return } + if engine == nil { isStarted = false; start() } + else { run { [weak self] in try await self?.synchronizeNow() } } + } + + func noteLocalChanges(_ mutations: VaultMutationSet) { + guard !mutations.isEmpty else { return } + addPendingChanges(mutations.keys) + guard isEnabled, engine != nil else { return } + run { [weak self] in try await self?.sendPendingChanges() } + } + + func confirmAccountSwitchMerge() { + guard isEnabled else { return } + do { + let state = try journal.state() + guard let pendingUser = state.pendingUserRecordName else { return } + try journal.markAllLiveRecordsNeedsUploadForNewAccount() + state.boundUserRecordName = pendingUser + state.pendingUserRecordName = nil + try context.save() + engine = nil + isStarted = false + start() + } catch { + context.rollback() + status = .failed(error.localizedDescription) + } + } + + private func run(_ body: @escaping @MainActor () async throws -> Void) { + guard operation == nil else { return } + operation = Task { [weak self] in + defer { self?.operation = nil } + do { try await body() } + catch is CancellationError {} + catch { self?.status = Self.status(for: error) } + } + } + + private func prepareAndSynchronize() async throws { + status = .checkingAccount + let accountStatus = try await container.accountStatus() + guard accountStatus == .available else { + status = .unavailable(Self.accountStatusDescription(accountStatus)) + isStarted = false + return + } + + let userRecordID = try await container.userRecordID() + let currentUser = userRecordID.recordName + let state = try journal.state() + if let boundUser = state.boundUserRecordName, boundUser != currentUser { + state.pendingUserRecordName = currentUser + try context.save() + status = .accountChangeConfirmationRequired + isStarted = false + return + } + state.boundUserRecordName = currentUser + state.pendingUserRecordName = nil + + let serialization = state.engineStateData.flatMap { + try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0) + } + var configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: serialization, + delegate: self + ) + configuration.automaticallySync = true + configuration.subscriptionID = Self.subscriptionID + let engine = CKSyncEngine(configuration) + self.engine = engine + + let pending = try journal.pendingEnvelopes() + addPendingChanges(Set(pending.map(\.key))) + try context.save() + + // Zone creation is idempotent and deliberately precedes the first fetch; + // no local record is sent until the server side has been read. + let zone = CKRecordZone(zoneID: zoneID) + _ = try await container.privateCloudDatabase.modifyRecordZones( + saving: [zone], deleting: [] + ) + try await engine.fetchChanges() + try await sendPendingChanges() + try updateUpToDateStatus() + } + + private func synchronizeNow() async throws { + guard let engine else { return } + status = .syncing(pendingCount: try journal.pendingEnvelopes().count) + try await engine.fetchChanges() + try await sendPendingChanges() + try updateUpToDateStatus() + } + + private func sendPendingChanges() async throws { + guard let engine, isEnabled else { return } + let pending = try journal.pendingEnvelopes() + guard !pending.isEmpty else { return } + addPendingChanges(Set(pending.map(\.key))) + status = .syncing(pendingCount: pending.count) + try await engine.sendChanges() + } + + private func addPendingChanges(_ keys: Set) { + guard let engine else { return } + engine.state.add(pendingRecordZoneChanges: keys.map { + .saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID)) + }) + } + + private func updateUpToDateStatus() throws { + if let quarantineReason = try journal.firstQuarantineReason() { + status = .failed(quarantineReason) + return + } + let pendingCount = try journal.pendingEnvelopes().count + let state = try journal.state() + if pendingCount == 0 { + state.lastSuccessfulSync = .now + try context.save() + status = .upToDate(lastSuccessfulSync: state.lastSuccessfulSync) + } else { + status = .syncing(pendingCount: pendingCount) + } + } + + // MARK: CKSyncEngineDelegate + + nonisolated func handleEvent( + _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine + ) async { + await handle(event, engine: syncEngine) + } + + nonisolated func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + await makeChangeBatch(context, engine: syncEngine) + } + + @MainActor + private func makeChangeBatch( + _ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let pending = engine.state.pendingRecordZoneChanges.filter { + context.options.scope.contains($0) + } + return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { + [weak self] recordID in + guard let self else { return nil } + return await self.recordToSave(recordID) + } + } + + @MainActor + private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? { + guard let key = VaultRecordKey(recordName: recordID.recordName), + let outgoing = try? journal.outgoingEntry(for: key) + else { return nil } + do { return try Self.makeRecord(outgoing, recordID: recordID) } + catch { + status = .failed(error.localizedDescription) + return nil + } + } + + @MainActor + private func handle(_ event: CKSyncEngine.Event, engine: CKSyncEngine) async { + do { + switch event { + case .stateUpdate(let update): + let state = try journal.state() + state.engineStateData = try JSONEncoder().encode(update.stateSerialization) + try context.save() + + case .accountChange(let change): + try handleAccountChange(change) + + case .fetchedRecordZoneChanges(let changes): + try handleFetchedChanges(changes, engine: engine) + + case .fetchedDatabaseChanges(let changes): + if changes.deletions.contains(where: { $0.zoneID == zoneID }) { + try handleZoneDeletion(engine: engine) + } + + case .sentRecordZoneChanges(let changes): + try handleSentChanges(changes, engine: engine) + + case .didFetchChanges, .didSendChanges: + try updateUpToDateStatus() + + default: + break + } + } catch { + context.rollback() + status = Self.status(for: error) + } + } + + private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) throws { + let state = try journal.state() + switch change.changeType { + case .signIn(let currentUser): + if let bound = state.boundUserRecordName, bound != currentUser.recordName { + stopEngineForAccountChange() + state.pendingUserRecordName = currentUser.recordName + status = .accountChangeConfirmationRequired + } + case .signOut: + stopEngineForAccountChange() + status = .unavailable(String(localized: "Sign in to iCloud to sync the Vault.")) + case .switchAccounts(_, let currentUser): + stopEngineForAccountChange() + state.pendingUserRecordName = currentUser.recordName + status = .accountChangeConfirmationRequired + @unknown default: + stopEngineForAccountChange() + status = .unavailable(String(localized: "The iCloud account changed.")) + } + try context.save() + } + + private func stopEngineForAccountChange() { + let activeEngine = engine + engine = nil + isStarted = false + if let activeEngine { Task { await activeEngine.cancelOperations() } } + } + + private func handleFetchedChanges( + _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine + ) throws { + var recordsByKey: [VaultRecordKey: CKRecord] = [:] + var envelopes: [VaultEnvelope] = [] + var quarantinedError: String? + for modification in changes.modifications { + let record = modification.record + guard record.recordType == Self.recordType, record.recordID.zoneID == zoneID else { + continue + } + do { + let envelope = try Self.envelope(from: record) + try journal.clearQuarantine(recordName: record.recordID.recordName) + recordsByKey[envelope.key] = record + envelopes.append(envelope) + } catch { + try journal.quarantine( + recordName: record.recordID.recordName, + payloadData: (try? Self.payloadData(from: record)) ?? Data(), + reason: error.localizedDescription + ) + quarantinedError = error.localizedDescription + } + } + + let result = try persistence.applyRemote(envelopes) + for (key, record) in recordsByKey { + try journal.updateSystemFields(Self.systemFields(of: record), for: key) + } + try journal.markNeedsUpload(result.needsUpload) + + // Physical deletes are not part of the protocol; recreate the local + // envelope instead of converting a console accident into data loss. + let physicallyDeleted = Set(changes.deletions.compactMap { + VaultRecordKey(recordName: $0.recordID.recordName) + }).filter { (try? journal.envelope(for: $0)) != nil } + try journal.markNeedsUpload(physicallyDeleted) + addPendingChanges(result.needsUpload.union(physicallyDeleted)) + try context.save() + if !result.acceptedKeys.isEmpty { + didApplyRemote?(result.snapshot, result.acceptedKeys) + } + if let quarantinedError { status = .failed(quarantinedError) } + } + + private func handleSentChanges( + _ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine + ) throws { + for record in changes.savedRecords { + guard let envelope = try? Self.envelope(from: record) else { continue } + try journal.markUploaded(envelope.key, version: envelope.version) + try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key) + try? FileManager.default.removeItem(at: Self.assetURL(for: envelope.key)) + } + + for failure in changes.failedRecordSaves { + let error = failure.error + if error.code == .serverRecordChanged, let serverRecord = error.serverRecord, + let serverEnvelope = try? Self.envelope(from: serverRecord) + { + let result = try persistence.applyRemote([serverEnvelope]) + try journal.updateSystemFields( + Self.systemFields(of: serverRecord), for: serverEnvelope.key + ) + try journal.markNeedsUpload(result.needsUpload) + addPendingChanges(result.needsUpload) + if !result.acceptedKeys.isEmpty { + didApplyRemote?(result.snapshot, result.acceptedKeys) + } + } else if error.code == .zoneNotFound { + try handleZoneDeletion(engine: engine) + } else if !Self.isRetryable(error) { + status = .failed(error.localizedDescription) + } + } + try context.save() + } + + private func handleZoneDeletion(engine: CKSyncEngine) throws { + try journal.markAllEntriesNeedsUploadAfterZoneReset() + let zone = CKRecordZone(zoneID: zoneID) + engine.state.add(pendingDatabaseChanges: [.saveZone(zone)]) + addPendingChanges(Set((try journal.pendingEnvelopes()).map(\.key))) + try context.save() + } + + // MARK: Record coding + + nonisolated static func makeRecord( + _ outgoing: VaultOutgoingEntry, recordID: CKRecord.ID + ) throws -> CKRecord { + let envelope = outgoing.envelope + let record: CKRecord + if let data = outgoing.systemFieldsData, + let restored = try Self.record(fromSystemFields: data) + { + record = restored + } else { + record = CKRecord(recordType: recordType, recordID: recordID) + } + let payload = VaultSyncCodec.encode(envelope) + record["formatVersion"] = NSNumber(value: envelope.formatVersion) + record["kind"] = envelope.key.kind.rawValue as NSString + record["entityID"] = envelope.key.id.uuidString.lowercased() as NSString + record["modifiedAt"] = envelope.version.modifiedAt as NSDate + record["counter"] = NSNumber(value: Int64(clamping: envelope.version.counter)) + record["writerID"] = envelope.version.writerID.uuidString.lowercased() as NSString + record["isDeleted"] = NSNumber(value: envelope.isDeleted) + record["payloadHash"] = SHA256.hash(data: payload).hexString as NSString + if payload.count > inlinePayloadLimit { + let url = try assetURL(for: envelope.key) + try payload.write(to: url, options: .atomic) + record["payload"] = nil + record["payloadAsset"] = CKAsset(fileURL: url) + } else { + record["payload"] = payload as NSData + record["payloadAsset"] = nil + } + return record + } + + nonisolated static func envelope(from record: CKRecord) throws -> VaultEnvelope { + let data = try payloadData(from: record) + let expectedHash = record["payloadHash"] as? String + guard expectedHash == SHA256.hash(data: data).hexString else { + throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + let envelope = try VaultSyncCodec.decode(data) + guard envelope.key.recordName == record.recordID.recordName, envelope.isValid else { + throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + return envelope + } + + nonisolated static func payloadData(from record: CKRecord) throws -> Data { + if let inline = record["payload"] as? Data { return inline } + if let asset = record["payloadAsset"] as? CKAsset, let url = asset.fileURL { + return try Data(contentsOf: url) + } + throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName) + } + + nonisolated static func systemFields(of record: CKRecord) throws -> Data { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + record.encodeSystemFields(with: archiver) + archiver.finishEncoding() + return archiver.encodedData + } + + private nonisolated static func record(fromSystemFields data: Data) throws -> CKRecord? { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + defer { unarchiver.finishDecoding() } + return CKRecord(coder: unarchiver) + } + + private nonisolated static func assetURL(for key: VaultRecordKey) throws -> URL { + let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appending(path: "Termind/VaultSyncAssets", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appending(path: key.recordName.replacingOccurrences(of: "/", with: "-")) + } + + private nonisolated static func isRetryable(_ error: CKError) -> Bool { + switch error.code { + case .networkFailure, .networkUnavailable, .serviceUnavailable, + .requestRateLimited, .zoneBusy, .resultsTruncated: + true + default: false + } + } + + private nonisolated static func status(for error: any Error) -> VaultSyncStatus { + if let cloudError = error as? CKError { + if cloudError.code == .notAuthenticated { + return .unavailable(String(localized: "Sign in to iCloud to sync the Vault.")) + } + if isRetryable(cloudError) { return .syncing(pendingCount: 0) } + } + return .failed(error.localizedDescription) + } + + private nonisolated static func accountStatusDescription( + _ status: CKAccountStatus + ) -> String { + switch status { + case .noAccount: String(localized: "Sign in to iCloud to sync the Vault.") + case .restricted: String(localized: "iCloud access is restricted on this Mac.") + case .couldNotDetermine: String(localized: "The iCloud account could not be checked.") + case .temporarilyUnavailable: String(localized: "iCloud is temporarily unavailable.") + case .available: "" + @unknown default: String(localized: "iCloud is unavailable.") + } + } +} + +private extension SHA256.Digest { + nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift b/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift new file mode 100644 index 00000000..97b3ad4f --- /dev/null +++ b/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift @@ -0,0 +1,443 @@ +import Foundation +import SwiftData + +@Model +final class PersistentVaultSyncEntry { + @Attribute(.unique) var recordName = "" + var kind = VaultRecordKind.host.rawValue + var entityID = UUID() + var envelopeData = Data() + var systemFieldsData: Data? + var needsUpload = false + + init(envelope: VaultEnvelope, needsUpload: Bool) throws { + apply(envelope, needsUpload: needsUpload) + } + + func apply(_ envelope: VaultEnvelope, needsUpload: Bool) { + recordName = envelope.key.recordName + kind = envelope.key.kind.rawValue + entityID = envelope.key.id + envelopeData = VaultSyncCodec.encode(envelope) + self.needsUpload = needsUpload + } + + var envelope: VaultEnvelope? { try? VaultSyncCodec.decode(envelopeData) } +} + +@Model +final class PersistentVaultSyncState { + @Attribute(.unique) var key = "vault-sync" + var installationID = UUID() + var engineStateData: Data? + var boundUserRecordName: String? + var pendingUserRecordName: String? + var bootstrapCompleted = false + var lastSuccessfulSync: Date? + + init() {} +} + +@Model +final class PersistentVaultSyncQuarantine { + @Attribute(.unique) var recordName = "" + var payloadData = Data() + var reason = "" + var receivedAt = Date() + + init(recordName: String, payloadData: Data, reason: String) { + self.recordName = recordName + self.payloadData = payloadData + self.reason = reason + } +} + +nonisolated enum VaultSyncPersistenceError: LocalizedError { + case invalidEnvelope(String) + case unsupportedFormat(Int) + + var errorDescription: String? { + switch self { + case .invalidEnvelope(let recordName): + String(localized: "The iCloud Vault record \(recordName) is invalid.") + case .unsupportedFormat(let version): + String(localized: "This Vault record requires sync format \(version).") + } + } +} + +nonisolated enum VaultSyncCodec { + static func encode(_ envelope: VaultEnvelope) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(envelope)) ?? Data() + } + + static func decode(_ data: Data) throws -> VaultEnvelope { + try JSONDecoder().decode(VaultEnvelope.self, from: data) + } +} + +nonisolated struct VaultRemoteMerge: Sendable { + var accepted: [VaultEnvelope] = [] + var needsUpload: Set = [] +} + +nonisolated struct VaultOutgoingEntry: Sendable { + var envelope: VaultEnvelope + var systemFieldsData: Data? +} + +extension VaultSnapshot { + nonisolated var recordPayloads: [VaultRecordKey: VaultRecordPayload] { + var result: [VaultRecordKey: VaultRecordPayload] = [:] + for value in hosts { result[VaultRecordKey(kind: .host, id: value.id)] = .host(value) } + for value in groups { + result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value) + } + for value in identities { + result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value) + } + for value in keys { + result[VaultRecordKey(kind: .sshKey, id: value.id)] = .sshKey(value) + } + for value in knownHosts { + result[VaultRecordKey(kind: .knownHost, id: value.id)] = .knownHost(value) + } + for value in snippets { + result[VaultRecordKey(kind: .snippet, id: value.id)] = .snippet(value) + } + for value in snippetPackages { + result[VaultRecordKey(kind: .snippetPackage, id: value.id)] = .snippetPackage(value) + } + for value in portForwardings { + result[VaultRecordKey(kind: .portForwarding, id: value.id)] = .portForwarding(value) + } + return result + } + + nonisolated mutating func apply(_ envelope: VaultEnvelope) throws { + guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { + throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) + } + guard envelope.isValid else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + + if envelope.isDeleted { + remove(envelope.key) + return + } + guard let payload = envelope.payload else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + switch payload { + case .host(let value): upsert(value, in: &hosts) + case .hostGroup(let value): upsert(value, in: &groups) + case .identity(let value): upsert(value, in: &identities) + case .sshKey(let value): upsert(value, in: &keys) + case .knownHost(let value): upsert(value, in: &knownHosts) + case .snippet(let value): upsert(value, in: &snippets) + case .snippetPackage(let value): upsert(value, in: &snippetPackages) + case .portForwarding(let value): upsert(value, in: &portForwardings) + } + } + + private nonisolated mutating func remove(_ key: VaultRecordKey) { + switch key.kind { + case .host: hosts.removeAll { $0.id == key.id } + case .hostGroup: groups.removeAll { $0.id == key.id } + case .identity: identities.removeAll { $0.id == key.id } + case .sshKey: keys.removeAll { $0.id == key.id } + case .knownHost: knownHosts.removeAll { $0.id == key.id } + case .snippet: snippets.removeAll { $0.id == key.id } + case .snippetPackage: snippetPackages.removeAll { $0.id == key.id } + case .portForwarding: portForwardings.removeAll { $0.id == key.id } + } + } + + private nonisolated func upsert( + _ value: Value, in values: inout [Value] + ) where Value.ID == UUID { + if let index = values.firstIndex(where: { $0.id == value.id }) { values[index] = value } + else { values.append(value) } + } +} + +@MainActor +final class VaultSyncJournal { + private let context: ModelContext + + init(context: ModelContext) { self.context = context } + + func state() throws -> PersistentVaultSyncState { + var descriptor = FetchDescriptor() + descriptor.fetchLimit = 1 + if let existing = try context.fetch(descriptor).first { return existing } + let state = PersistentVaultSyncState() + context.insert(state) + return state + } + + @discardableResult + func bootstrapIfNeeded(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { + let state = try state() + guard !state.bootstrapCompleted else { return .empty } + let existing = try entriesByName() + var changed = Set() + for payload in snapshot.recordPayloads.values { + let key = payload.key + guard existing[key.recordName] == nil else { continue } + let envelope = VaultEnvelope( + key: key, version: .baseline(writerID: state.installationID), payload: payload + ) + context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)) + changed.insert(key) + } + state.bootstrapCompleted = true + return VaultMutationSet(keys: changed) + } + + func recordLocalChanges( + from oldSnapshot: VaultSnapshot, to newSnapshot: VaultSnapshot, + now: Date = .now + ) throws -> VaultMutationSet { + let state = try state() + let old = oldSnapshot.recordPayloads + let new = newSnapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in new where old[key] != payload { + let row = entries[key.recordName] + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + if let row { row.apply(envelope, needsUpload: true) } + else { + let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true) + context.insert(inserted) + entries[key.recordName] = inserted + } + changed.insert(key) + } + + for key in old.keys where new[key] == nil { + let row = entries[key.recordName] + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + isDeleted: true + ) + if let row { row.apply(envelope, needsUpload: true) } + else { + let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true) + context.insert(inserted) + entries[key.recordName] = inserted + } + changed.insert(key) + } + return VaultMutationSet(keys: changed) + } + + func pendingEnvelopes() throws -> [VaultEnvelope] { + try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.needsUpload } + ) + ).compactMap(\.envelope) + } + + func outgoingEntry(for key: VaultRecordKey) throws -> VaultOutgoingEntry? { + guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { + return nil + } + return VaultOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData) + } + + func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? { + try entry(recordName: key.recordName)?.envelope + } + + func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge { + var result = VaultRemoteMerge() + let entries = try entriesByName() + for envelope in remote { + guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { + throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) + } + guard envelope.isValid else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + guard let local = entries[envelope.key.recordName]?.envelope else { + result.accepted.append(envelope) + continue + } + if local.version < envelope.version { result.accepted.append(envelope) } + else if envelope.version < local.version { result.needsUpload.insert(local.key) } + else if local != envelope { + let localData = VaultSyncCodec.encode(local) + let remoteData = VaultSyncCodec.encode(envelope) + if localData.lexicographicallyPrecedes(remoteData) { + result.accepted.append(envelope) + } else { + result.needsUpload.insert(local.key) + } + } + } + return result + } + + func acceptRemote(_ envelopes: [VaultEnvelope]) throws { + var entries = try entriesByName() + for envelope in envelopes { + if let row = entries[envelope.key.recordName] { + row.apply(envelope, needsUpload: false) + } else { + let row = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: false) + context.insert(row) + entries[envelope.key.recordName] = row + } + } + } + + func markNeedsUpload(_ keys: Set) throws { + let entries = try entriesByName() + for key in keys { entries[key.recordName]?.needsUpload = true } + } + + func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws { + guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { + return + } + row.needsUpload = false + } + + func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws { + try entry(recordName: key.recordName)?.systemFieldsData = data + } + + func quarantine(recordName: String, payloadData: Data, reason: String) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName } + ) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { + row.payloadData = payloadData + row.reason = reason + row.receivedAt = .now + } else { + context.insert(PersistentVaultSyncQuarantine( + recordName: recordName, payloadData: payloadData, reason: reason + )) + } + } + + func clearQuarantine(recordName: String) throws { + try context.delete( + model: PersistentVaultSyncQuarantine.self, + where: #Predicate { $0.recordName == recordName } + ) + } + + func firstQuarantineReason() throws -> String? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first?.reason + } + + func recordCurrentSnapshot(_ snapshot: VaultSnapshot, now: Date = .now) throws + -> VaultMutationSet + { + let state = try state() + let current = snapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in current { + let row = entries[key.recordName] + guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { + continue + } + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + if let row { row.apply(envelope, needsUpload: true) } + else { + let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true) + context.insert(inserted) + entries[key.recordName] = inserted + } + changed.insert(key) + } + + for row in entries.values { + guard let envelope = row.envelope, !envelope.isDeleted, + current[envelope.key] == nil + else { continue } + let tombstone = VaultEnvelope( + key: envelope.key, + version: .next(after: envelope.version, writerID: state.installationID, now: now), + isDeleted: true + ) + row.apply(tombstone, needsUpload: true) + changed.insert(envelope.key) + } + return VaultMutationSet(keys: changed) + } + + func markAllLiveRecordsNeedsUploadForNewAccount() throws { + for row in try context.fetch(FetchDescriptor()) { + guard let envelope = row.envelope else { continue } + if envelope.isDeleted { context.delete(row) } + else { + row.needsUpload = true + row.systemFieldsData = nil + } + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + func markAllEntriesNeedsUploadAfterZoneReset() throws { + for row in try context.fetch(FetchDescriptor()) { + row.needsUpload = true + row.systemFieldsData = nil + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + private func entry(recordName: String) throws -> PersistentVaultSyncEntry? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func entriesByName() throws -> [String: PersistentVaultSyncEntry] { + Dictionary( + try context.fetch(FetchDescriptor()).map { + ($0.recordName, $0) + }, + uniquingKeysWith: { first, duplicate in + if let firstVersion = first.envelope?.version, + let duplicateVersion = duplicate.envelope?.version, + firstVersion < duplicateVersion + { + return duplicate + } + return first + } + ) + } +} diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index efb4499f..d80aa90e 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -356,6 +356,10 @@ final class PersistentPortForwardingRule { init(_ value: PortForwardingRule) { id = value.id + apply(value) + } + + func apply(_ value: PortForwardingRule) { hostID = value.hostID kind = value.kind.rawValue bindHost = value.bindHost @@ -713,6 +717,60 @@ enum TermindSchemaV14: VersionedSchema { } } +/// Frozen sync models from the first development build that wrote a V15 store. +/// The live models later gained `systemFieldsData` and +/// `pendingUserRecordName`; keeping the original types here preserves V15's +/// on-disk checksum so staged migration can recognize those stores. +enum TermindSchemaV15Models { + @Model + final class PersistentVaultSyncEntry { + @Attribute(.unique) var recordName = "" + var kind = VaultRecordKind.host.rawValue + var entityID = UUID() + var envelopeData = Data() + var needsUpload = false + + init() {} + } + + @Model + final class PersistentVaultSyncState { + @Attribute(.unique) var key = "vault-sync" + var installationID = UUID() + var engineStateData: Data? + var boundUserRecordName: String? + var bootstrapCompleted = false + var lastSuccessfulSync: Date? + + init() {} + } +} + +enum TermindSchemaV15: VersionedSchema { + static let versionIdentifier = Schema.Version(15, 0, 0) + static var models: [any PersistentModel.Type] { + TermindSchemaV14.models + [ + TermindSchemaV15Models.PersistentVaultSyncEntry.self, + TermindSchemaV15Models.PersistentVaultSyncState.self, + ] + } +} + +/// V15 shipped briefly in development with only the journal and engine state +/// models. Keep that shape frozen so stores created by those builds retain a +/// valid migration source checksum. Quarantined remote records were added in +/// V16 instead of mutating the already-used V15 schema in place. +enum TermindSchemaV16: VersionedSchema { + static let versionIdentifier = Schema.Version(16, 0, 0) + static var models: [any PersistentModel.Type] { + TermindSchemaV14.models + [ + PersistentVaultSyncEntry.self, + PersistentVaultSyncState.self, + PersistentVaultSyncQuarantine.self, + ] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ @@ -721,6 +779,8 @@ enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV6.self, TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self, TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, TermindSchemaV13.self, TermindSchemaV14.self, + TermindSchemaV15.self, + TermindSchemaV16.self, ] } @@ -791,13 +851,21 @@ enum TermindMigrationPlan: SchemaMigrationPlan { fromVersion: TermindSchemaV13.self, toVersion: TermindSchemaV14.self ), + .lightweight( + fromVersion: TermindSchemaV14.self, + toVersion: TermindSchemaV15.self + ), + .lightweight( + fromVersion: TermindSchemaV15.self, + toVersion: TermindSchemaV16.self + ), ] } } @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV14.self) + static let schema = Schema(versionedSchema: TermindSchemaV16.self) let context: ModelContext @@ -807,6 +875,13 @@ final class HostPersistence { func load() throws -> VaultSnapshot { try migrateLegacyCloudStorageNames() + let snapshot = try fetchSnapshot() + _ = try VaultSyncJournal(context: context).bootstrapIfNeeded(snapshot) + if context.hasChanges { try context.save() } + return snapshot + } + + private func fetchSnapshot() throws -> VaultSnapshot { let hosts = try context.fetch(FetchDescriptor()).map(\.value) let groups = try context.fetch(FetchDescriptor()).map(\.value) let identities = try context.fetch(FetchDescriptor()).map(\.value) @@ -849,32 +924,108 @@ final class HostPersistence { } } - func save(_ snapshot: VaultSnapshot) throws { + func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { do { - try replace(PersistentHost.self, with: snapshot.hosts.map(PersistentHost.init)) - try replace(PersistentHostGroup.self, with: snapshot.groups.map(PersistentHostGroup.init)) - try replace(PersistentIdentity.self, with: snapshot.identities.map(PersistentIdentity.init)) - try replace(PersistentSSHKey.self, with: snapshot.keys.map(PersistentSSHKey.init)) - try replace(PersistentKnownHost.self, with: snapshot.knownHosts.map(PersistentKnownHost.init)) - try replace(PersistentSnippet.self, with: snapshot.snippets.map(PersistentSnippet.init)) - try replace( - PersistentSnippetPackage.self, - with: snapshot.snippetPackages.map(PersistentSnippetPackage.init) - ) - try replace( - PersistentPortForwardingRule.self, - with: snapshot.portForwardings.map(PersistentPortForwardingRule.init) - ) + let oldSnapshot = try fetchSnapshot() + let journal = VaultSyncJournal(context: context) + _ = try journal.bootstrapIfNeeded(oldSnapshot) + let mutations = try journal.recordLocalChanges(from: oldSnapshot, to: snapshot) + try persist(snapshot) try context.save() + return mutations } catch { context.rollback() throw error } } - private func replace(_ type: Model.Type, with values: [Model]) throws { - try context.delete(model: type) - for value in values { context.insert(value) } + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + do { + let journal = VaultSyncJournal(context: context) + let decision = try journal.mergeDecision(for: envelopes) + var snapshot = try fetchSnapshot() + for envelope in decision.accepted { try snapshot.apply(envelope) } + try persist(snapshot) + try journal.acceptRemote(decision.accepted) + try journal.markNeedsUpload(decision.needsUpload) + try context.save() + return VaultRemoteApplyResult( + snapshot: snapshot, + acceptedKeys: Set(decision.accepted.map(\.key)), + needsUpload: decision.needsUpload + ) + } catch { + context.rollback() + throw error + } + } + + func recordExternalMutation() throws -> VaultMutationSet { + let snapshot = try fetchSnapshot() + let journal = VaultSyncJournal(context: context) + _ = try journal.bootstrapIfNeeded(snapshot) + return try journal.recordCurrentSnapshot(snapshot) + } + + private func persist(_ snapshot: VaultSnapshot) throws { + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.hosts, + rowID: \.id, valueID: \.id, make: PersistentHost.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.groups, + rowID: \.id, valueID: \.id, + make: PersistentHostGroup.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.identities, + rowID: \.id, valueID: \.id, + make: PersistentIdentity.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.keys, + rowID: \.id, valueID: \.id, + make: PersistentSSHKey.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.knownHosts, + rowID: \.id, valueID: \.id, + make: PersistentKnownHost.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.snippets, + rowID: \.id, valueID: \.id, + make: PersistentSnippet.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), + with: snapshot.snippetPackages, rowID: \.id, valueID: \.id, + make: PersistentSnippetPackage.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), + with: snapshot.portForwardings, rowID: \.id, valueID: \.id, + make: PersistentPortForwardingRule.init, apply: { $0.apply($1) } + ) + } + + private func reconcile( + _ rows: [Model], with values: [Value], + rowID: (Model) -> UUID, valueID: (Value) -> UUID, + make: (Value) -> Model, apply: (Model, Value) -> Void + ) throws { + var byID: [UUID: Model] = [:] + for row in rows { + let id = rowID(row) + if byID[id] == nil { byID[id] = row } + else { context.delete(row) } + } + let wanted = Set(values.map(valueID)) + for row in byID.values where !wanted.contains(rowID(row)) { context.delete(row) } + for value in values { + if let row = byID[valueID(value)] { apply(row, value) } + else { context.insert(make(value)) } + } } } diff --git a/Termind/Infrastructure/Database/SwiftDataHostRepository.swift b/Termind/Infrastructure/Database/SwiftDataHostRepository.swift index 8314e82e..b3cc281d 100644 --- a/Termind/Infrastructure/Database/SwiftDataHostRepository.swift +++ b/Termind/Infrastructure/Database/SwiftDataHostRepository.swift @@ -6,6 +6,7 @@ final class SwiftDataHostRepository: HostRepository { private let context: ModelContext private var continuations: [UUID: AsyncStream>.Continuation] = [:] + var didRecordVaultChanges: ((VaultMutationSet) -> Void)? init(context: ModelContext) { self.context = context @@ -140,7 +141,9 @@ final class SwiftDataHostRepository: HostRepository { private func commitMutation() throws { do { let revision = try advanceRevisionWithoutSaving() + let vaultMutations = try HostPersistence(context: context).recordExternalMutation() try context.save() + if !vaultMutations.isEmpty { didRecordVaultChanges?(vaultMutations) } try publishCommittedMutation(revision: revision) } catch { context.rollback() diff --git a/Termind/Termind.entitlements b/Termind/Termind.entitlements index ee02c479..402adc1f 100644 --- a/Termind/Termind.entitlements +++ b/Termind/Termind.entitlements @@ -20,6 +20,18 @@ com.apple.security.network.server + com.apple.developer.aps-environment + $(APS_ENVIRONMENT) + com.apple.developer.icloud-container-identifiers + + iCloud.com.akinokaede.termind + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-environment + $(ICLOUD_CONTAINER_ENVIRONMENT) com.apple.security.files.user-selected.read-write diff --git a/TermindTests/AIProviderPersistenceTests.swift b/TermindTests/AIProviderPersistenceTests.swift index d0dbdc52..a9037100 100644 --- a/TermindTests/AIProviderPersistenceTests.swift +++ b/TermindTests/AIProviderPersistenceTests.swift @@ -180,14 +180,14 @@ struct AIProviderPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV4.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) container.mainContext.insert(PersistentHost(host)) try container.mainContext.save() } let schema = Schema(versionedSchema: TermindSchemaV5.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer( for: schema, migrationPlan: TermindMigrationPlan.self, @@ -217,7 +217,7 @@ struct AIProviderPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV9.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) container.mainContext.insert(PersistentModelProvider(provider, sortIndex: 0)) let model = TermindSchemaV9AIModels.PersistentAIModel() @@ -227,7 +227,7 @@ struct AIProviderPersistenceTests { try container.mainContext.save() } - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: HostPersistence.schema, url: storeURL ) let container = try ModelContainer( @@ -244,7 +244,7 @@ struct AIProviderPersistenceTests { } private func makeContainer(_ name: String) throws -> ModelContainer { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( name, schema: HostPersistence.schema, isStoredInMemoryOnly: true ) return try ModelContainer( diff --git a/TermindTests/AgentInterruptedRunRecoveryTests.swift b/TermindTests/AgentInterruptedRunRecoveryTests.swift index d684862c..382cedec 100644 --- a/TermindTests/AgentInterruptedRunRecoveryTests.swift +++ b/TermindTests/AgentInterruptedRunRecoveryTests.swift @@ -73,7 +73,7 @@ struct AgentInterruptedRunRecoveryTests { @MainActor @Test func restoreRecoversResultsRecordedBeforeTheProcessDied() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentInterruptedRunRecoveryTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) diff --git a/TermindTests/AgentPersistenceTests.swift b/TermindTests/AgentPersistenceTests.swift index 0f7e09a9..76e0d697 100644 --- a/TermindTests/AgentPersistenceTests.swift +++ b/TermindTests/AgentPersistenceTests.swift @@ -18,7 +18,7 @@ struct AgentPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV8.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) let row = TermindSchemaV8Models.PersistentAgentMessage() row.id = messageID @@ -27,7 +27,7 @@ struct AgentPersistenceTests { try container.mainContext.save() } - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: HostPersistence.schema, url: storeURL ) let container = try ModelContainer( @@ -55,7 +55,7 @@ struct AgentPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV10.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) let conversation = TermindSchemaV10AgentModels.PersistentAgentConversation() conversation.id = conversationID @@ -68,7 +68,7 @@ struct AgentPersistenceTests { try container.mainContext.save() } - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: HostPersistence.schema, url: storeURL ) let container = try ModelContainer( @@ -101,7 +101,7 @@ struct AgentPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV11.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) let message = TermindSchemaV11AgentModels.PersistentAgentMessage() message.id = messageID @@ -110,7 +110,7 @@ struct AgentPersistenceTests { try container.mainContext.save() } - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: HostPersistence.schema, url: storeURL ) let container = try ModelContainer( @@ -136,7 +136,7 @@ struct AgentPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV12.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) let invocation = TermindSchemaV12AgentModels.PersistentAgentToolInvocation() invocation.id = invocationID @@ -146,7 +146,7 @@ struct AgentPersistenceTests { try container.mainContext.save() } - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: HostPersistence.schema, url: storeURL ) let container = try ModelContainer( @@ -173,7 +173,7 @@ struct AgentPersistenceTests { do { let schema = Schema(versionedSchema: TermindSchemaV13.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) let message = TermindSchemaV13AgentModels.PersistentAgentMessage() message.id = messageID @@ -182,7 +182,7 @@ struct AgentPersistenceTests { try container.mainContext.save() } - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: HostPersistence.schema, url: storeURL ) let container = try ModelContainer( @@ -200,7 +200,7 @@ struct AgentPersistenceTests { @Test func imagePartsRoundTripThroughConversationPersistence() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentImagePersistenceTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -226,7 +226,7 @@ struct AgentPersistenceTests { @Test func displayReasoningRoundTripsRedactedWithoutProviderState() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentReasoningPersistenceTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -268,7 +268,7 @@ struct AgentPersistenceTests { @Test func runOwnershipAndToolSourceMessageRoundTrip() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentWorkPersistenceTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -311,7 +311,7 @@ struct AgentPersistenceTests { @Test func unfinishedRunsInterruptAndVisibleHistoryRestoresWithoutSecrets() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentPersistenceTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -371,7 +371,7 @@ struct AgentPersistenceTests { @Test func fetchHeaderSecretsAreRedactedFromToolCallsAndInvocationRows() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentFetchHeaderRedactionTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -425,7 +425,7 @@ struct AgentPersistenceTests { /// already correct. @Test func replayedHistoryIsNotRewrittenByEveryLaterRun() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentPersistenceReplayTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -480,7 +480,7 @@ struct AgentPersistenceTests { @Test func outOfOrderPersistenceEventsNeverDowngradeToolState() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentPersistenceEventOrderTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -512,7 +512,7 @@ struct AgentPersistenceTests { @Test func deletingConversationRemovesItsCompletePersistentGraph() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentPersistenceDeletionTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -568,7 +568,7 @@ struct AgentPersistenceTests { /// back to notice. @Test func aTitleIsWrittenOnlyBySummaryAndSurvivesRestore() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentPersistenceTitleTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -605,7 +605,7 @@ struct AgentPersistenceTests { @Test func metadataAndAuthoredTextSurviveRestore() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentConversationMetadataTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -638,7 +638,7 @@ struct AgentPersistenceTests { @Test func truncationRemovesTheTargetRunAndEverythingAfterIt() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentConversationTruncationTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -677,7 +677,7 @@ struct AgentPersistenceTests { @Test func truncationRejectsAMessageOutsideThePersistedConversation() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentConversationMissingEditTargetTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) @@ -710,7 +710,7 @@ struct AgentPersistenceTests { @Test func creatingAForkPersistsAnIndependentConversationGraph() async throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "AgentConversationForkTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) diff --git a/TermindTests/CommandHistoryTests.swift b/TermindTests/CommandHistoryTests.swift index fa2ddf0b..ffe8aeae 100644 --- a/TermindTests/CommandHistoryTests.swift +++ b/TermindTests/CommandHistoryTests.swift @@ -107,7 +107,7 @@ struct CommandHistoryTests { } private func makeContainer(_ name: String) throws -> ModelContainer { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( name, schema: HostPersistence.schema, isStoredInMemoryOnly: true ) return try ModelContainer( diff --git a/TermindTests/KeychainOrderTests.swift b/TermindTests/KeychainOrderTests.swift index da72d346..e145fc52 100644 --- a/TermindTests/KeychainOrderTests.swift +++ b/TermindTests/KeychainOrderTests.swift @@ -62,7 +62,7 @@ struct KeychainOrderTests { @Test func keyAndIdentityOrdersSurvivePersistenceReload() throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "KeychainOrderTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) diff --git a/TermindTests/LocalModelConfiguration.swift b/TermindTests/LocalModelConfiguration.swift new file mode 100644 index 00000000..cbf09706 --- /dev/null +++ b/TermindTests/LocalModelConfiguration.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftData + +/// Tests run inside the signed app host. Once that host has an iCloud +/// entitlement SwiftData's `.automatic` default would incorrectly attempt to +/// mirror every test store through CloudKit. Production also opts out because +/// Vault sync is owned explicitly by `VaultSyncCoordinator`. +func localModelConfiguration( + _ name: String? = nil, + schema: Schema? = nil, + isStoredInMemoryOnly: Bool = false, + allowsSave: Bool = true, + groupContainer: ModelConfiguration.GroupContainer = .automatic +) -> ModelConfiguration { + ModelConfiguration( + name, schema: schema, isStoredInMemoryOnly: isStoredInMemoryOnly, + allowsSave: allowsSave, groupContainer: groupContainer, + cloudKitDatabase: .none + ) +} + +func localModelConfiguration( + _ name: String? = nil, + schema: Schema? = nil, + url: URL, + allowsSave: Bool = true +) -> ModelConfiguration { + ModelConfiguration( + name, schema: schema, url: url, allowsSave: allowsSave, + cloudKitDatabase: .none + ) +} diff --git a/TermindTests/PortForwardingFeatureTests.swift b/TermindTests/PortForwardingFeatureTests.swift index 6bd4d0e3..7fc7725b 100644 --- a/TermindTests/PortForwardingFeatureTests.swift +++ b/TermindTests/PortForwardingFeatureTests.swift @@ -138,7 +138,7 @@ struct PortForwardingFeatureTests { @Test func termindV1RoundTripKeepsRoutesAndDropsDynamicTargets() throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "PortForwardingFeatureTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) diff --git a/TermindTests/RepositoryOutboxTests.swift b/TermindTests/RepositoryOutboxTests.swift index 9d4a3efa..6a5d4b2d 100644 --- a/TermindTests/RepositoryOutboxTests.swift +++ b/TermindTests/RepositoryOutboxTests.swift @@ -47,7 +47,7 @@ struct RepositoryOutboxTests { do { let schema = Schema(versionedSchema: TermindSchemaV3.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer(for: schema, configurations: [configuration]) let context = container.mainContext context.insert(TermindSchemaV3Models.PersistentIdentity(identity)) @@ -58,7 +58,7 @@ struct RepositoryOutboxTests { } let schema = Schema(versionedSchema: TermindSchemaV5.self) - let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) let container = try ModelContainer( for: schema, migrationPlan: TermindMigrationPlan.self, configurations: [configuration] @@ -109,7 +109,7 @@ struct RepositoryOutboxTests { do { let schema = Schema(versionedSchema: TermindSchemaV1.self) - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: schema, url: storeURL ) let container = try ModelContainer( @@ -130,7 +130,7 @@ struct RepositoryOutboxTests { } let schema = Schema(versionedSchema: TermindSchemaV5.self) - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "Migration", schema: schema, url: storeURL ) let container = try ModelContainer( @@ -258,7 +258,7 @@ struct RepositoryOutboxTests { } private func makeContainer(_ name: String) throws -> ModelContainer { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( name, schema: HostPersistence.schema, isStoredInMemoryOnly: true ) return try ModelContainer( diff --git a/TermindTests/SnippetFeatureTests.swift b/TermindTests/SnippetFeatureTests.swift index bedcfeee..8130bf77 100644 --- a/TermindTests/SnippetFeatureTests.swift +++ b/TermindTests/SnippetFeatureTests.swift @@ -90,7 +90,7 @@ struct SnippetFeatureTests { @Test func snippetPersistenceRoundTripKeepsPackagesTargetsAndExpansion() throws { - let configuration = ModelConfiguration( + let configuration = localModelConfiguration( "SnippetFeatureTests", schema: HostPersistence.schema, isStoredInMemoryOnly: true ) diff --git a/TermindTests/VaultSyncTests.swift b/TermindTests/VaultSyncTests.swift new file mode 100644 index 00000000..1f97eb2d --- /dev/null +++ b/TermindTests/VaultSyncTests.swift @@ -0,0 +1,233 @@ +import CloudKit +import Foundation +import SwiftData +import Testing + +@testable import Termind + +@Suite +@MainActor +struct VaultSyncTests { + @Test + func allVaultPayloadsRoundTripWithStableRecordNames() throws { + let host = Host(name: "Production", host: "prod.example") + let group = HostGroup(name: "Servers") + let identity = Identity(name: "Deploy", username: "deploy") + let key = SSHKeyRef(label: "Deploy Key", publicKey: "ssh-ed25519 AAAA") + let knownHost = KnownHostEntry(hostname: "prod.example", fingerprint: "SHA256:test") + let package = SnippetPackage(name: "Operations") + let snippet = Snippet(title: "Uptime", body: "uptime", packageID: package.id) + let forwarding = PortForwardingRule(hostID: host.id) + let payloads: [VaultRecordPayload] = [ + .host(host), .hostGroup(group), .identity(identity), .sshKey(key), + .knownHost(knownHost), .snippet(snippet), .snippetPackage(package), + .portForwarding(forwarding), + ] + let writer = UUID() + + for payload in payloads { + let envelope = VaultEnvelope( + key: payload.key, version: .baseline(writerID: writer), payload: payload + ) + let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) + #expect(decoded == envelope) + #expect(VaultRecordKey(recordName: payload.key.recordName) == payload.key) + } + } + + @Test + func journalRecordsOnlyRealChangesAndKeepsDeletionTombstones() throws { + let container = try makeContainer("JournalDiff") + let context = container.mainContext + let persistence = HostPersistence(context: context) + let host = Host(name: "Build", host: "build.example") + let initial = VaultSnapshot(hosts: [host]) + + let first = try persistence.save(initial) + #expect(first.keys == [VaultRecordKey(kind: .host, id: host.id)]) + #expect(try persistence.save(initial).isEmpty) + + let deletion = try persistence.save(VaultSnapshot()) + let key = VaultRecordKey(kind: .host, id: host.id) + #expect(deletion.keys == [key]) + let tombstone = try #require( + try VaultSyncJournal(context: context).envelope(for: key) + ) + #expect(tombstone.isDeleted) + #expect(tombstone.payload == nil) + } + + @Test + func newerRemoteRecordWinsAndOlderRemoteRecordIsRequeued() throws { + let container = try makeContainer("RemoteMerge") + let context = container.mainContext + let persistence = HostPersistence(context: context) + var host = Host(name: "Local", host: "local.example") + _ = try persistence.save(VaultSnapshot(hosts: [host])) + let key = VaultRecordKey(kind: .host, id: host.id) + let local = try #require( + try VaultSyncJournal(context: context).envelope(for: key) + ) + + host.name = "Remote" + let newer = VaultEnvelope( + key: key, + version: VaultVersion( + modifiedAt: local.version.modifiedAt.addingTimeInterval(10), + counter: 0, writerID: UUID() + ), + payload: .host(host) + ) + let accepted = try persistence.applyRemote([newer]) + #expect(accepted.snapshot.hosts.first?.name == "Remote") + #expect(accepted.acceptedKeys == [key]) + + let older = VaultEnvelope( + key: key, version: .baseline(writerID: UUID()), + payload: .host(Host(id: host.id, name: "Stale", host: "stale.example")) + ) + let rejected = try persistence.applyRemote([older]) + #expect(rejected.snapshot.hosts.first?.name == "Remote") + #expect(rejected.needsUpload == [key]) + } + + @Test + func repositoryOwnedHostWritesAlsoEnterTheDurableJournal() throws { + let container = try makeContainer("RepositoryJournal") + let context = container.mainContext + _ = try HostPersistence(context: context).load() + let repository = SwiftDataHostRepository(context: context) + let host = Host(name: "Repository", host: "repository.example") + var published = VaultMutationSet.empty + repository.didRecordVaultChanges = { published = $0 } + + try repository.upsertHost(host) + + let key = VaultRecordKey(kind: .host, id: host.id) + #expect(published.keys == [key]) + let envelope = try #require( + try VaultSyncJournal(context: context).envelope(for: key) + ) + #expect(envelope.payload == .host(host)) + #expect( + try context.fetch(FetchDescriptor()).first?.needsUpload + == true + ) + } + + @Test + func encodedVaultMetadataContainsReferencesButNoCredentialMaterial() throws { + let secret = "private-material-must-never-sync" + let identity = Identity( + name: "Deploy", username: "deploy", + credentialRef: "termind.password.reference", passwordStorage: .iCloud + ) + let envelope = VaultEnvelope( + key: VaultRecordKey(kind: .identity, id: identity.id), + version: .baseline(writerID: UUID()), payload: .identity(identity) + ) + let encoded = String(decoding: VaultSyncCodec.encode(envelope), as: UTF8.self) + #expect(encoded.contains(identity.credentialRef)) + #expect(!encoded.contains(secret)) + } + + @Test + func cloudRecordUsesAssetForLargePayloadAndRejectsCorruption() throws { + let host = Host(name: String(repeating: "A", count: 710_000), host: "large.example") + let envelope = VaultEnvelope( + key: VaultRecordKey(kind: .host, id: host.id), + version: VaultVersion(modifiedAt: .now, counter: 0, writerID: UUID()), + payload: .host(host) + ) + let recordID = CKRecord.ID( + recordName: envelope.key.recordName, + zoneID: CKRecordZone.ID( + zoneName: VaultSyncCoordinator.zoneName, + ownerName: CKCurrentUserDefaultName + ) + ) + let record = try VaultSyncCoordinator.makeRecord( + VaultOutgoingEntry(envelope: envelope), recordID: recordID + ) + #expect(record["payload"] == nil) + #expect(record["payloadAsset"] as? CKAsset != nil) + #expect(try VaultSyncCoordinator.envelope(from: record) == envelope) + + record["payloadHash"] = "bad" as NSString + #expect(throws: (any Error).self) { try VaultSyncCoordinator.envelope(from: record) } + } + + @Test + func versionFourteenMigratesToSyncJournalWithoutLosingVault() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "VaultSyncMigration-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appending(path: "Termind.store") + let host = Host(name: "Migration", host: "migration.example") + + do { + let schema = Schema(versionedSchema: TermindSchemaV14.self) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) + let container = try ModelContainer(for: schema, configurations: [configuration]) + container.mainContext.insert(PersistentHost(host)) + try container.mainContext.save() + } + + let configuration = localModelConfiguration( + "Migration", schema: HostPersistence.schema, url: storeURL + ) + let container = try ModelContainer( + for: HostPersistence.schema, + migrationPlan: TermindMigrationPlan.self, + configurations: [configuration] + ) + let persistence = HostPersistence(context: container.mainContext) + #expect(try persistence.load().hosts == [host]) + let entry = try #require( + container.mainContext.fetch(FetchDescriptor()).first + ) + #expect(entry.envelope?.payload == .host(host)) + #expect(entry.needsUpload) + } + + @Test + func originalVersionFifteenStoreMigratesToQuarantineSchema() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "VaultSyncV15Migration-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appending(path: "Termind.store") + let host = Host(name: "V15 Migration", host: "v15.example") + + do { + let schema = Schema(versionedSchema: TermindSchemaV15.self) + let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL) + let container = try ModelContainer(for: schema, configurations: [configuration]) + container.mainContext.insert(PersistentHost(host)) + container.mainContext.insert(TermindSchemaV15Models.PersistentVaultSyncState()) + try container.mainContext.save() + } + + let configuration = localModelConfiguration( + "Migration", schema: HostPersistence.schema, url: storeURL + ) + let container = try ModelContainer( + for: HostPersistence.schema, + migrationPlan: TermindMigrationPlan.self, + configurations: [configuration] + ) + #expect(try container.mainContext.fetch(FetchDescriptor()).first?.value == host) + #expect(try container.mainContext.fetch(FetchDescriptor()).count == 1) + #expect(try container.mainContext.fetch(FetchDescriptor()).isEmpty) + } + + private func makeContainer(_ name: String) throws -> ModelContainer { + let configuration = localModelConfiguration( + name, schema: HostPersistence.schema, isStoredInMemoryOnly: true + ) + return try ModelContainer( + for: HostPersistence.schema, configurations: [configuration] + ) + } +}