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.
484 lines
19 KiB
Swift
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)
|
|
}
|
|
}
|