Files

497 lines
20 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
public nonisolated enum VaultSyncPersistenceError: LocalizedError {
case invalidEnvelope(String)
case unsupportedFormat(Int)
public 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).")
}
}
}
public nonisolated struct VaultRemoteMerge: Sendable {
public var accepted: [VaultEnvelope] = []
public var needsUpload: Set<VaultRecordKey> = []
}
public nonisolated struct VaultOutgoingEntry: Sendable {
public var envelope: VaultEnvelope
public var systemFieldsData: Data?
public init(envelope: VaultEnvelope, systemFieldsData: Data? = nil) {
self.envelope = envelope
self.systemFieldsData = systemFieldsData
}
}
extension VaultSnapshot {
public nonisolated var recordPayloads: [VaultRecordKey: VaultRecordPayload] {
var result: [VaultRecordKey: VaultRecordPayload] = [:]
for value in hosts { result[VaultRecordKey(kind: .host, id: value.id)] = .host(value) }
for var value in groups {
value.isExpanded = true
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 var value in snippetPackages {
value.isExpanded = true
result[VaultRecordKey(kind: .snippetPackage, id: value.id)] = .snippetPackage(value)
}
for value in portForwardings {
result[VaultRecordKey(kind: .portForwarding, id: value.id)] = .portForwarding(value)
}
for value in workspaceTemplates {
result[VaultRecordKey(kind: .workspaceTemplate, id: value.id)] = .workspaceTemplate(value)
}
return result
}
public nonisolated mutating func apply(_ envelope: VaultEnvelope) throws {
try SyncRecordFormats.vault.validate(kind: envelope.key.kind.rawValue, version: 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(var value):
value.createdAt = value.createdAt ?? hosts.first(where: { $0.id == value.id })?.createdAt
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)
case .workspaceTemplate(let value): upsert(value, in: &workspaceTemplates)
case .connectionHistory:
// Connection history is persisted independently by HostPersistence
// so HostLibrary snapshots cannot accidentally replace it.
break
case .device: break
}
}
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 }
case .workspaceTemplate: workspaceTemplates.removeAll { $0.id == key.id }
case .connectionHistory: break
case .device: break
}
}
private nonisolated func upsert<Value: Identifiable>(
_ 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)
}
}
}
extension VaultRecordPayload {
fileprivate var withoutDeviceLocalMetadata: Self {
switch self {
case .hostGroup(var value):
value.isExpanded = true
return .hostGroup(value)
case .snippetPackage(var value):
value.isExpanded = true
return .snippetPackage(value)
default:
return self
}
}
}
extension VaultEnvelope {
var withoutDeviceLocalMetadata: Self {
var copy = self
copy.payload = payload?.withoutDeviceLocalMetadata
return copy
}
}
@MainActor
public final class VaultSyncJournal {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func state() throws -> PersistentVaultSyncState {
var descriptor = FetchDescriptor<PersistentVaultSyncState>()
descriptor.fetchLimit = 1
if let existing = try context.fetch(descriptor).first { return existing }
let state = PersistentVaultSyncState()
context.insert(state)
return state
}
@discardableResult
public func bootstrapIfNeeded(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
let state = try state()
guard !state.bootstrapCompleted else { return .empty }
let existing = try entriesByName()
var changed = Set<VaultRecordKey>()
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)
}
public func recordLocalChanges(
from oldSnapshot: VaultSnapshot, to newSnapshot: VaultSnapshot,
touching touchedKeys: Set<VaultRecordKey> = [],
now: Date = .now
) throws -> VaultMutationSet {
let state = try state()
let old = oldSnapshot.recordPayloads
let new = newSnapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in new where old[key] != payload || touchedKeys.contains(key) {
let row = entries[key.recordName]
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
try store(envelope, needsUpload: true, in: &entries)
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
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
return VaultMutationSet(keys: changed)
}
public func pendingEnvelopes() throws -> [VaultEnvelope] {
try context.fetch(
FetchDescriptor<PersistentVaultSyncEntry>(
predicate: #Predicate { $0.needsUpload }
)
).compactMap(\.envelope).filter { $0.key.kind.isConfiguration }.map(\.withoutDeviceLocalMetadata)
}
public func outgoingEntry(for key: VaultRecordKey, scope: CloudSyncScope? = nil) throws -> VaultOutgoingEntry? {
// Restored pre-V35 CKSyncEngine queues can still contain legacy history IDs.
guard key.kind.isConfiguration else { return nil }
if let scope,
try CloudSyncSafetyStore(context: context, scope: scope).isUploadBlocked(recordName: key.recordName)
{
return nil
}
guard let row = try entry(recordName: key.recordName), row.needsUpload, let envelope = row.envelope,
!envelope.isDeleted
else {
return nil
}
return VaultOutgoingEntry(
envelope: envelope.withoutDeviceLocalMetadata,
systemFieldsData: row.systemFieldsData
)
}
public func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? {
try entry(recordName: key.recordName)?.envelope?.withoutDeviceLocalMetadata
}
public func modificationDates() throws -> [VaultRecordKey: Date] {
var result: [VaultRecordKey: Date] = [:]
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>())
where HistoryRecordKey(recordName: row.recordName) == nil {
guard let envelope = row.envelope, !envelope.isDeleted else { continue }
result[envelope.key] = envelope.version.modifiedAt
}
return result
}
public func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge {
var result = VaultRemoteMerge()
let entries = try entriesByName()
for rawEnvelope in remote where rawEnvelope.key.kind.isConfiguration {
var envelope = rawEnvelope.withoutDeviceLocalMetadata
try SyncRecordFormats.vault.validate(kind: envelope.key.kind.rawValue, version: envelope.formatVersion)
guard envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
guard
let local = entries[envelope.key.recordName]?.envelope?.withoutDeviceLocalMetadata
else {
result.accepted.append(envelope)
continue
}
if local.isDeleted && !envelope.isDeleted && entries[envelope.key.recordName]?.needsUpload == true {
result.needsUpload.insert(local.key)
continue
}
if local.version < envelope.version {
if case .host(var incoming) = envelope.payload, incoming.createdAt == nil,
case .host(let existing) = local.payload, let createdAt = existing.createdAt
{
incoming.createdAt = createdAt
envelope.payload = .host(incoming)
envelope.formatVersion = 8
result.needsUpload.insert(envelope.key)
}
result.accepted.append(envelope)
} else if envelope.version < local.version {
result.needsUpload.insert(local.key)
} else if local != envelope {
let localData = try VaultSyncCodec.encode(local)
let remoteData = try VaultSyncCodec.encode(envelope)
if localData.lexicographicallyPrecedes(remoteData) {
result.accepted.append(envelope)
} else {
result.needsUpload.insert(local.key)
}
}
}
return result
}
public func acceptRemote(_ envelopes: [VaultEnvelope]) throws {
var entries = try entriesByName()
for envelope in envelopes where envelope.key.kind.isConfiguration {
try store(envelope.withoutDeviceLocalMetadata, needsUpload: false, in: &entries)
}
}
public func markNeedsUpload(_ keys: Set<VaultRecordKey>) throws {
let entries = try entriesByName()
for key in keys { entries[key.recordName]?.needsUpload = true }
}
public func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws {
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope,
envelope.version == version
else {
return
}
let normalized = envelope.withoutDeviceLocalMetadata
guard row.needsUpload || envelope != normalized else { return }
try row.apply(normalized, needsUpload: false)
}
public func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws {
guard let row = try entry(recordName: key.recordName), row.systemFieldsData != data else { return }
row.systemFieldsData = data
}
public func clearSystemFields(recordName: String) throws {
try entry(recordName: recordName)?.systemFieldsData = nil
}
public func recordCurrentSnapshot(_ snapshot: VaultSnapshot, now: Date = .now) throws
-> VaultMutationSet
{
let state = try state()
let current = snapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in current {
let row = entries[key.recordName]
guard
row?.envelope?.withoutDeviceLocalMetadata.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
)
try store(envelope, needsUpload: true, in: &entries)
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
)
try row.apply(tombstone, needsUpload: true)
changed.insert(envelope.key)
}
return VaultMutationSet(keys: changed)
}
public func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>())
where HistoryRecordKey(recordName: row.recordName) == nil {
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
}
public func markAllEntriesNeedsUploadAfterZoneReset() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>())
where HistoryRecordKey(recordName: row.recordName) == nil {
row.needsUpload = true
row.systemFieldsData = nil
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
/// CloudKit record system fields include environment-specific change tags.
/// Keep every local envelope, but discard those tags and the serialized
/// engine state before moving between Development and Production.
public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws {
try markAllEntriesNeedsUploadAfterZoneReset()
}
private func entry(recordName: String) throws -> PersistentVaultSyncEntry? {
var descriptor = FetchDescriptor<PersistentVaultSyncEntry>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func store(
_ envelope: VaultEnvelope, needsUpload: Bool,
in entries: inout [String: PersistentVaultSyncEntry]
) throws {
if let row = entries[envelope.key.recordName] {
if row.envelope?.isDeleted == true && !envelope.isDeleted { row.systemFieldsData = nil }
try row.apply(envelope, needsUpload: needsUpload)
} else {
let row = try PersistentVaultSyncEntry(
envelope: envelope, needsUpload: needsUpload
)
context.insert(row)
entries[envelope.key.recordName] = row
}
}
private func entriesByName() throws -> [String: PersistentVaultSyncEntry] {
Dictionary(
try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).filter {
HistoryRecordKey(recordName: $0.recordName) == nil
}.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
}
)
}
}
extension VaultSyncJournal {
public func cloudRecords() throws -> [CloudSyncJournalRecord] {
try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).compactMap { row in
guard let envelope = row.envelope else { return nil }
let key = envelope.key
guard key.kind.isConfiguration else { return nil }
return CloudSyncJournalRecord(
recordName: key.recordName, version: envelope.version, isDeleted: envelope.isDeleted,
needsUpload: row.needsUpload, hasSystemFields: row.systemFieldsData != nil)
}
}
public func deletionEnvelopes(_ keys: Set<VaultRecordKey>) throws -> [VaultEnvelope] {
try keys.compactMap { key in
guard key.kind.isConfiguration else { return nil }
let row = try entry(recordName: key.recordName)
// Preserve undecodable local data until a compatible client can reconcile it.
if let row, row.envelope == nil { return nil }
let old = row?.envelope
if let old, old.isDeleted { return old }
return VaultEnvelope(
key: key, version: .next(after: old?.version, writerID: try state().installationID), isDeleted: true)
}
}
public func acknowledgeDeletion(_ mutation: CloudSyncMutation, scope: CloudSyncScope) throws {
guard let key = VaultRecordKey(recordName: mutation.recordName), key.kind.isConfiguration,
let row = try entry(recordName: mutation.recordName), let envelope = row.envelope
else { return }
if !envelope.isDeleted && row.needsUpload && mutation.version < envelope.version {
// A later explicit recreation is still pending; acknowledge only the old server incarnation.
try updateSystemFields(nil, for: key)
try CloudSyncRecordStateStore(context: context, scope: scope).deleted(
key.recordName, version: mutation.version)
return
}
guard envelope.isDeleted, envelope.version == mutation.version else { return }
try markUploaded(key, version: mutation.version)
try updateSystemFields(nil, for: key)
try CloudSyncRecordStateStore(context: context, scope: scope).deleted(key.recordName, version: mutation.version)
}
}