Files
Termind/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift
T
Kaede Akino 1ab7ed3da3 feat: isolate tailscale connections and harden history
Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment.

Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision.

Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
2026-10-03 09:46:09 +08:00

484 lines
19 KiB
Swift

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(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)
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 {
let 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 {
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)
}
}