Refine Identity sync
This commit is contained in:
1 parent
fae324d00e
commit
71d92a384f
12 files changed
+372
-32
No files matched your search
@@ -130,11 +130,12 @@ struct IdentityCredentialUsageTests {
|
||||
let dates = snapshot.identities.compactMap { store.identity($0.id)?.lastUsedAt }
|
||||
#expect(dates.count == 3)
|
||||
#expect(Set(dates).count == 1)
|
||||
#expect(persistence.saveCount == 1)
|
||||
#expect(persistence.identityUsageSaveCount == 1)
|
||||
#expect(persistence.saveCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usageDatesAreMonotonicAndFailedPersistenceRollsBack() {
|
||||
func usageDatesAreMonotonicAndRepeatedReadsPersistOncePerProcess() {
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let persistence = CredentialAccessPersistence(
|
||||
snapshot: VaultSnapshot(identities: [identity])
|
||||
@@ -148,14 +149,59 @@ struct IdentityCredentialUsageTests {
|
||||
)
|
||||
|
||||
#expect(store.identity(identity.id)?.lastUsedAt == newer)
|
||||
#expect(persistence.saveCount == 1)
|
||||
#expect(persistence.identityUsageSaveCount == 1)
|
||||
|
||||
persistence.failsSave = true
|
||||
store.recordIdentityCredentialAccess(
|
||||
[identity.id], at: Date(timeIntervalSince1970: 300)
|
||||
)
|
||||
#expect(store.identity(identity.id)?.lastUsedAt == newer)
|
||||
#expect(persistence.identityUsageSaveCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedUsagePersistenceRollsBackAndCanRetry() {
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let persistence = CredentialAccessPersistence(
|
||||
snapshot: VaultSnapshot(identities: [identity])
|
||||
)
|
||||
let store = HostLibraryModel(persistence: persistence)
|
||||
let usedAt = Date(timeIntervalSince1970: 300)
|
||||
|
||||
persistence.failsIdentityUsageSave = true
|
||||
store.recordIdentityCredentialAccess([identity.id], at: usedAt)
|
||||
#expect(store.identity(identity.id)?.lastUsedAt == nil)
|
||||
#expect(store.storageError != nil)
|
||||
|
||||
persistence.failsIdentityUsageSave = false
|
||||
store.recordIdentityCredentialAccess([identity.id], at: usedAt)
|
||||
#expect(store.identity(identity.id)?.lastUsedAt == usedAt)
|
||||
#expect(persistence.identityUsageSaveCount == 2)
|
||||
#expect(store.storageError == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleIdentitySavePreservesUsageAndCredentialChangesAllowAnotherRecord() throws {
|
||||
let usedAt = Date(timeIntervalSince1970: 200)
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let persistence = CredentialAccessPersistence(
|
||||
snapshot: VaultSnapshot(identities: [identity])
|
||||
)
|
||||
let store = HostLibraryModel(persistence: persistence)
|
||||
store.recordIdentityCredentialAccess([identity.id], at: usedAt)
|
||||
|
||||
var edited = identity
|
||||
edited.name = "Renamed"
|
||||
edited.lastUsedAt = Date(timeIntervalSince1970: 100)
|
||||
#expect(store.upsert(edited))
|
||||
#expect(store.identity(identity.id)?.lastUsedAt == usedAt)
|
||||
|
||||
edited = try #require(store.identity(identity.id))
|
||||
edited.username = "root"
|
||||
#expect(store.upsert(edited))
|
||||
let later = Date(timeIntervalSince1970: 300)
|
||||
store.recordIdentityCredentialAccess([identity.id], at: later)
|
||||
#expect(store.identity(identity.id)?.lastUsedAt == later)
|
||||
#expect(persistence.identityUsageSaveCount == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -187,9 +233,11 @@ struct IdentityCredentialUsageTests {
|
||||
|
||||
@MainActor
|
||||
private final class CredentialAccessPersistence: VaultPersisting {
|
||||
let snapshot: VaultSnapshot
|
||||
var snapshot: VaultSnapshot
|
||||
var saveCount = 0
|
||||
var failsSave = false
|
||||
var identityUsageSaveCount = 0
|
||||
var failsIdentityUsageSave = false
|
||||
|
||||
init(snapshot: VaultSnapshot) {
|
||||
self.snapshot = snapshot
|
||||
@@ -200,9 +248,21 @@ private final class CredentialAccessPersistence: VaultPersisting {
|
||||
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
|
||||
saveCount += 1
|
||||
if failsSave { throw CocoaError(.fileWriteUnknown) }
|
||||
self.snapshot = snapshot
|
||||
return .empty
|
||||
}
|
||||
|
||||
func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {
|
||||
identityUsageSaveCount += 1
|
||||
if failsIdentityUsageSave { throw CocoaError(.fileWriteUnknown) }
|
||||
for index in snapshot.identities.indices {
|
||||
guard let date = updates[snapshot.identities[index].id],
|
||||
snapshot.identities[index].lastUsedAt.map({ $0 >= date }) != true
|
||||
else { continue }
|
||||
snapshot.identities[index].lastUsedAt = date
|
||||
}
|
||||
}
|
||||
|
||||
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
||||
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
public private(set) var storageError: String?
|
||||
private let vault: VaultStore
|
||||
private let keychain: any SecretStoring
|
||||
@ObservationIgnored private var recordedIdentityCredentialAccesses: Set<Identity.ID> = []
|
||||
/// Runtime resources live in AppModel, so deletion announces before model
|
||||
/// references are removed and the controller can stop listeners cleanly.
|
||||
public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
|
||||
@@ -658,16 +659,46 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
public func recordIdentityCredentialAccess(
|
||||
_ identityIDs: Set<Identity.ID>, at date: Date = Date()
|
||||
) {
|
||||
guard !identityIDs.isEmpty else { return }
|
||||
let pending = identityIDs.subtracting(recordedIdentityCredentialAccesses)
|
||||
guard !pending.isEmpty else { return }
|
||||
let previous = identities
|
||||
var changed = false
|
||||
for index in identities.indices where identityIDs.contains(identities[index].id) {
|
||||
if identities[index].lastUsedAt.map({ $0 >= date }) == true { continue }
|
||||
identities[index].lastUsedAt = date
|
||||
changed = true
|
||||
var updates: [Identity.ID: Date] = [:]
|
||||
var found: Set<Identity.ID> = []
|
||||
for index in identities.indices where pending.contains(identities[index].id) {
|
||||
let id = identities[index].id
|
||||
found.insert(id)
|
||||
if identities[index].lastUsedAt.map({ $0 >= date }) != true {
|
||||
identities[index].lastUsedAt = date
|
||||
updates[id] = date
|
||||
}
|
||||
}
|
||||
guard changed else { return }
|
||||
if !persistCore() { identities = previous }
|
||||
guard !found.isEmpty else { return }
|
||||
do {
|
||||
try vault.saveIdentityLastUsedAt(updates)
|
||||
recordedIdentityCredentialAccesses.formUnion(found)
|
||||
storageError = nil
|
||||
} catch {
|
||||
identities = previous
|
||||
storageError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private static func identityConnectionFieldsChanged(_ old: Identity, _ new: Identity) -> Bool {
|
||||
old.username != new.username
|
||||
|| old.credentialRef != new.credentialRef
|
||||
|| old.passwordStorage != new.passwordStorage
|
||||
|| old.keyID != new.keyID
|
||||
}
|
||||
|
||||
private static func keyConnectionFieldsChanged(_ old: SSHKeyRef, _ new: SSHKeyRef) -> Bool {
|
||||
old.algorithm != new.algorithm
|
||||
|| old.publicKey != new.publicKey
|
||||
|| old.certificate != new.certificate
|
||||
|| old.fingerprint != new.fingerprint
|
||||
|| old.credentialRef != new.credentialRef
|
||||
|| old.storage != new.storage
|
||||
|| old.hasPassphrase != new.hasPassphrase
|
||||
|| old.requiresUserPresence != new.requiresUserPresence
|
||||
}
|
||||
|
||||
/// Every host tag, deduplicated case-insensitively and sorted.
|
||||
@@ -957,9 +988,17 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
/// is sorted by name wherever it is shown, so there is no order to keep.
|
||||
@discardableResult
|
||||
public func upsert(_ identity: Identity) -> Bool {
|
||||
var resetsCredentialAccess = false
|
||||
if let index = identities.firstIndex(where: { $0.id == identity.id }) {
|
||||
let previous = identities[index]
|
||||
var updated = identity
|
||||
updated.sortIndex = identities[index].sortIndex
|
||||
updated.sortIndex = previous.sortIndex
|
||||
if let lastUsedAt = previous.lastUsedAt,
|
||||
updated.lastUsedAt.map({ $0 < lastUsedAt }) != false
|
||||
{
|
||||
updated.lastUsedAt = lastUsedAt
|
||||
}
|
||||
resetsCredentialAccess = Self.identityConnectionFieldsChanged(previous, updated)
|
||||
identities[index] = updated
|
||||
} else {
|
||||
var new = identity
|
||||
@@ -972,7 +1011,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
hosts[index].proxy?.username = ""
|
||||
}
|
||||
}
|
||||
return persistCore()
|
||||
let saved = persistCore()
|
||||
if saved, resetsCredentialAccess { recordedIdentityCredentialAccesses.remove(identity.id) }
|
||||
return saved
|
||||
}
|
||||
|
||||
/// Updates an Identity and its Keychain password as one operation.
|
||||
@@ -1058,6 +1099,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
storageError ?? String(localized: "The identity could not be saved.")
|
||||
)
|
||||
}
|
||||
if case .replace = passwordUpdate { recordedIdentityCredentialAccesses.remove(updated.id) }
|
||||
|
||||
let removesPrevious =
|
||||
!previousReference.isEmpty
|
||||
@@ -1146,11 +1188,14 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
}
|
||||
return false
|
||||
}
|
||||
recordedIdentityCredentialAccesses.remove(identityID)
|
||||
return true
|
||||
}
|
||||
|
||||
public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws {
|
||||
let previousKey = self.key(key.id)
|
||||
let resetsCredentialAccess =
|
||||
credential != nil || previousKey.map { Self.keyConnectionFieldsChanged($0, key) } == true
|
||||
if key.storage == .secureEnclave, !key.certificate.isEmpty {
|
||||
throw TermindSSHError.library(
|
||||
String(localized: "Secure Enclave keys cannot store an SSH certificate.")
|
||||
@@ -1214,6 +1259,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
storageError ?? String(localized: "The key could not be saved.")
|
||||
)
|
||||
}
|
||||
if resetsCredentialAccess {
|
||||
recordedIdentityCredentialAccesses.subtract(identities(using: key.id).map(\.id))
|
||||
}
|
||||
|
||||
if changesLocation, let previousKey {
|
||||
do {
|
||||
@@ -1266,6 +1314,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
public func delete(keyID: SSHKeyRef.ID) throws {
|
||||
guard let key = key(keyID) else { return }
|
||||
let affectedIdentityIDs = Set(identities(using: keyID).map(\.id))
|
||||
let credential = try keyCredential(for: key)
|
||||
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
|
||||
let keySnapshot = keys
|
||||
@@ -1286,6 +1335,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
storageError ?? String(localized: "The key could not be deleted.")
|
||||
)
|
||||
}
|
||||
recordedIdentityCredentialAccesses.subtract(affectedIdentityIDs)
|
||||
}
|
||||
|
||||
/// Adds or updates a group. A new group is placed after its last sibling
|
||||
@@ -1751,6 +1801,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
/// Applies a CloudKit-imported projection without writing it back as a new
|
||||
/// local edit. Runtime connection and transfer state intentionally remain.
|
||||
public func replacePersistedSnapshot(_ snapshot: VaultSnapshot) {
|
||||
let previousIdentities = Dictionary(uniqueKeysWithValues: identities.map { ($0.id, $0) })
|
||||
let previousKeys = Dictionary(uniqueKeysWithValues: keys.map { ($0.id, $0) })
|
||||
hosts = snapshot.hosts
|
||||
groups = snapshot.groups
|
||||
identities = normalizedManualOrder(
|
||||
@@ -1759,6 +1811,23 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
keys = normalizedManualOrder(
|
||||
snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label
|
||||
)
|
||||
let currentIdentityIDs = Set(identities.map(\.id))
|
||||
recordedIdentityCredentialAccesses.formIntersection(currentIdentityIDs)
|
||||
for identity in identities {
|
||||
if let previous = previousIdentities[identity.id],
|
||||
Self.identityConnectionFieldsChanged(previous, identity)
|
||||
{
|
||||
recordedIdentityCredentialAccesses.remove(identity.id)
|
||||
}
|
||||
}
|
||||
let currentKeys = Dictionary(uniqueKeysWithValues: keys.map { ($0.id, $0) })
|
||||
let changedKeyIDs = Set(previousKeys.keys).union(currentKeys.keys).filter { id in
|
||||
guard let previous = previousKeys[id], let current = currentKeys[id] else { return true }
|
||||
return Self.keyConnectionFieldsChanged(previous, current)
|
||||
}
|
||||
recordedIdentityCredentialAccesses.subtract(
|
||||
identities.filter { $0.keyID.map(changedKeyIDs.contains) == true }.map(\.id)
|
||||
)
|
||||
knownHosts = snapshot.knownHosts
|
||||
snippets = normalizedSnippetOrder(snapshot.snippets)
|
||||
snippetPackages = snapshot.snippetPackages
|
||||
|
||||
@@ -378,7 +378,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
encoder.outputFormatting = .sortedKeys
|
||||
var result = (try? encoder.encode(route)) ?? Data()
|
||||
for item in route {
|
||||
if let identity = hosts.identity(for: item) {
|
||||
if var identity = hosts.identity(for: item) {
|
||||
// Credential access updates presentation metadata after a connection
|
||||
// is prepared. It must not make Metrics mistake that same connection
|
||||
// for a configuration change and restart its worker on reconciliation.
|
||||
identity.lastUsedAt = nil
|
||||
result.append((try? encoder.encode(identity)) ?? Data())
|
||||
if let id = identity.keyID, let key = hosts.key(id) {
|
||||
result.append((try? encoder.encode(key)) ?? Data())
|
||||
|
||||
@@ -30,6 +30,13 @@ public final class VaultStore {
|
||||
didPersist?()
|
||||
}
|
||||
|
||||
/// Device-local usage metadata bypasses the Vault journal and repository
|
||||
/// refresh callbacks because it does not change connection configuration.
|
||||
public func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {
|
||||
guard !updates.isEmpty else { return }
|
||||
try persistence?.saveIdentityLastUsedAt(updates)
|
||||
}
|
||||
|
||||
public func modifiedAt(for key: VaultRecordKey) -> Date? {
|
||||
modificationDates[key]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import TermindDomain
|
||||
public protocol VaultPersisting: AnyObject {
|
||||
func load() throws -> VaultSnapshot
|
||||
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet
|
||||
func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws
|
||||
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult
|
||||
func modificationDates() throws -> [VaultRecordKey: Date]
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ extension VaultSnapshot {
|
||||
for value in groups {
|
||||
result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value)
|
||||
}
|
||||
for value in identities {
|
||||
for var value in identities {
|
||||
value.lastUsedAt = nil
|
||||
result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value)
|
||||
}
|
||||
for value in keys {
|
||||
@@ -77,7 +78,9 @@ extension VaultSnapshot {
|
||||
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 .identity(var value):
|
||||
value.lastUsedAt = identities.first(where: { $0.id == value.id })?.lastUsedAt
|
||||
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)
|
||||
@@ -110,6 +113,22 @@ extension VaultSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
extension VaultRecordPayload {
|
||||
fileprivate var withoutDeviceLocalMetadata: Self {
|
||||
guard case .identity(var value) = self else { return self }
|
||||
value.lastUsedAt = nil
|
||||
return .identity(value)
|
||||
}
|
||||
}
|
||||
|
||||
extension VaultEnvelope {
|
||||
fileprivate var withoutDeviceLocalMetadata: Self {
|
||||
var copy = self
|
||||
copy.payload = payload?.withoutDeviceLocalMetadata
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class VaultSyncJournal {
|
||||
private let context: ModelContext
|
||||
@@ -183,18 +202,21 @@ public final class VaultSyncJournal {
|
||||
FetchDescriptor<PersistentVaultSyncEntry>(
|
||||
predicate: #Predicate { $0.needsUpload }
|
||||
)
|
||||
).compactMap(\.envelope)
|
||||
).compactMap(\.envelope).map(\.withoutDeviceLocalMetadata)
|
||||
}
|
||||
|
||||
public 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)
|
||||
return VaultOutgoingEntry(
|
||||
envelope: envelope.withoutDeviceLocalMetadata,
|
||||
systemFieldsData: row.systemFieldsData
|
||||
)
|
||||
}
|
||||
|
||||
public func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? {
|
||||
try entry(recordName: key.recordName)?.envelope
|
||||
try entry(recordName: key.recordName)?.envelope?.withoutDeviceLocalMetadata
|
||||
}
|
||||
|
||||
public func modificationDates() throws -> [VaultRecordKey: Date] {
|
||||
@@ -209,14 +231,17 @@ public final class VaultSyncJournal {
|
||||
public func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge {
|
||||
var result = VaultRemoteMerge()
|
||||
let entries = try entriesByName()
|
||||
for envelope in remote {
|
||||
for rawEnvelope in remote {
|
||||
let envelope = rawEnvelope.withoutDeviceLocalMetadata
|
||||
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 {
|
||||
guard
|
||||
let local = entries[envelope.key.recordName]?.envelope?.withoutDeviceLocalMetadata
|
||||
else {
|
||||
result.accepted.append(envelope)
|
||||
continue
|
||||
}
|
||||
@@ -240,7 +265,7 @@ public final class VaultSyncJournal {
|
||||
public func acceptRemote(_ envelopes: [VaultEnvelope]) throws {
|
||||
var entries = try entriesByName()
|
||||
for envelope in envelopes {
|
||||
try store(envelope, needsUpload: false, in: &entries)
|
||||
try store(envelope.withoutDeviceLocalMetadata, needsUpload: false, in: &entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,10 +275,12 @@ public final class VaultSyncJournal {
|
||||
}
|
||||
|
||||
public func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws {
|
||||
guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else {
|
||||
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope,
|
||||
envelope.version == version
|
||||
else {
|
||||
return
|
||||
}
|
||||
row.needsUpload = false
|
||||
row.apply(envelope.withoutDeviceLocalMetadata, needsUpload: false)
|
||||
}
|
||||
|
||||
public func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws {
|
||||
@@ -302,7 +329,10 @@ public final class VaultSyncJournal {
|
||||
|
||||
for (key, payload) in current {
|
||||
let row = entries[key.recordName]
|
||||
guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else {
|
||||
guard
|
||||
row?.envelope?.withoutDeviceLocalMetadata.payload != payload
|
||||
|| row?.envelope?.isDeleted != false
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let envelope = VaultEnvelope(
|
||||
|
||||
@@ -51,6 +51,20 @@ public final class HostPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
public func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {
|
||||
guard !updates.isEmpty else { return }
|
||||
do {
|
||||
for row in try context.fetch(FetchDescriptor<PersistentIdentity>()) {
|
||||
guard let date = updates[row.id], row.lastUsedAt.map({ $0 >= date }) != true else { continue }
|
||||
row.lastUsedAt = date
|
||||
}
|
||||
if context.hasChanges { try context.save() }
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
||||
do {
|
||||
let journal = VaultSyncJournal(context: context)
|
||||
|
||||
@@ -97,12 +97,14 @@ authentication; callers never perform a separate discovery connection.
|
||||
Generation tokens prevent callbacks, credential results, and retry timers from a
|
||||
retired connection mutating its replacement.
|
||||
|
||||
An Identity's persisted `lastUsedAt` means its saved password or private key was
|
||||
An Identity's device-local `lastUsedAt` means its saved password or private key was
|
||||
successfully read for connection setup, not that remote authentication succeeded.
|
||||
Connection-specific resolvers batch those accesses across destinations, jump hosts,
|
||||
and authenticated proxies; missing secrets and ordinary Keychain availability or
|
||||
editing reads never update usage. Later transport failure does not roll the access
|
||||
back, while pooled or borrowed live connections do not reread or refresh it.
|
||||
editing reads never update usage. The first access for each Identity in an app process
|
||||
is written directly to its SwiftData row without entering the Vault journal or repository
|
||||
refresh path. Later retries are deduplicated, and Vault sync strips this local metadata
|
||||
while preserving it when applying remote Identity configuration.
|
||||
|
||||
The SSH worker's serial queue owns PTY dimensions. It retains the latest valid
|
||||
viewport during authentication and uses it for the initial PTY request, including
|
||||
|
||||
@@ -565,6 +565,9 @@ struct SSHImportTests {
|
||||
private final class FailingImportPersistence: VaultPersisting {
|
||||
func load() throws -> VaultSnapshot { VaultSnapshot() }
|
||||
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { throw CocoaError(.fileWriteUnknown) }
|
||||
func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
|
||||
@@ -499,6 +499,24 @@ struct ServerMetricsLifecycleTests {
|
||||
await eventually { fixture.time.delays.contains(8) }
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordingCredentialAccessDoesNotRestartConnection() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.model.shutdown() }
|
||||
fixture.model.add(fixture.host)
|
||||
let server = try #require(fixture.model.servers.first)
|
||||
await eventually { server.point != nil }
|
||||
await eventually { fixture.hosts.identity(for: fixture.host)?.lastUsedAt != nil }
|
||||
|
||||
fixture.model.reconcile()
|
||||
fixture.model.reconcile()
|
||||
|
||||
#expect(!server.isConnecting)
|
||||
#expect(server.collectionState == .live)
|
||||
#expect(await fixture.sampler.connections == 1)
|
||||
#expect(await fixture.sampler.disconnections == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func changingHostConfigurationRestartsConnection() async throws {
|
||||
let fixture = try Fixture()
|
||||
|
||||
@@ -167,6 +167,138 @@ struct VaultSyncTests {
|
||||
#expect(tombstone.payload == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func identityUsageStaysLocalAndDoesNotCreateVaultMutations() throws {
|
||||
let container = try makeContainer("LocalIdentityUsage")
|
||||
let context = container.mainContext
|
||||
let persistence = HostPersistence(context: context)
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let key = VaultRecordKey(kind: .identity, id: identity.id)
|
||||
_ = try persistence.save(VaultSnapshot(identities: [identity]))
|
||||
let journal = VaultSyncJournal(context: context)
|
||||
let initial = try #require(try journal.envelope(for: key))
|
||||
try journal.markUploaded(key, version: initial.version)
|
||||
try context.save()
|
||||
|
||||
let usedAt = Date(timeIntervalSince1970: 500)
|
||||
try persistence.saveIdentityLastUsedAt([identity.id: usedAt])
|
||||
|
||||
#expect(try persistence.load().identities.first?.lastUsedAt == usedAt)
|
||||
#expect(try journal.pendingEnvelopes().isEmpty)
|
||||
#expect(try journal.envelope(for: key)?.version == initial.version)
|
||||
if case .identity(let payload) = try journal.envelope(for: key)?.payload {
|
||||
#expect(payload.lastUsedAt == nil)
|
||||
} else {
|
||||
Issue.record("Expected an Identity payload")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func lastUsedOnlySnapshotSaveDoesNotAdvanceIdentityEnvelope() throws {
|
||||
let container = try makeContainer("LastUsedOnlySnapshot")
|
||||
let context = container.mainContext
|
||||
let persistence = HostPersistence(context: context)
|
||||
var identity = Identity(name: "Deploy", username: "deploy")
|
||||
let key = VaultRecordKey(kind: .identity, id: identity.id)
|
||||
_ = try persistence.save(VaultSnapshot(identities: [identity]))
|
||||
let initial = try #require(try VaultSyncJournal(context: context).envelope(for: key))
|
||||
identity.lastUsedAt = Date(timeIntervalSince1970: 800)
|
||||
|
||||
let mutations = try persistence.save(VaultSnapshot(identities: [identity]))
|
||||
|
||||
#expect(mutations.isEmpty)
|
||||
#expect(try VaultSyncJournal(context: context).envelope(for: key)?.version == initial.version)
|
||||
#expect(try persistence.load().identities.first?.lastUsedAt == identity.lastUsedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
func remoteIdentityPreservesLocalUsageAndStillAppliesConfiguration() throws {
|
||||
let container = try makeContainer("RemoteIdentityUsage")
|
||||
let context = container.mainContext
|
||||
let persistence = HostPersistence(context: context)
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let key = VaultRecordKey(kind: .identity, id: identity.id)
|
||||
_ = try persistence.save(VaultSnapshot(identities: [identity]))
|
||||
let localEnvelope = try #require(try VaultSyncJournal(context: context).envelope(for: key))
|
||||
let localUsage = Date(timeIntervalSince1970: 400)
|
||||
try persistence.saveIdentityLastUsedAt([identity.id: localUsage])
|
||||
|
||||
var remoteIdentity = identity
|
||||
remoteIdentity.name = "Remote Deploy"
|
||||
remoteIdentity.lastUsedAt = Date(timeIntervalSince1970: 900)
|
||||
let remote = VaultEnvelope(
|
||||
key: key,
|
||||
version: VaultVersion(
|
||||
modifiedAt: localEnvelope.version.modifiedAt.addingTimeInterval(10),
|
||||
counter: 0, writerID: UUID()
|
||||
),
|
||||
payload: .identity(remoteIdentity)
|
||||
)
|
||||
|
||||
let result = try persistence.applyRemote([remote])
|
||||
|
||||
#expect(result.snapshot.identities.first?.name == remoteIdentity.name)
|
||||
#expect(result.snapshot.identities.first?.lastUsedAt == localUsage)
|
||||
if case .identity(let payload) = try VaultSyncJournal(context: context).envelope(for: key)?.payload {
|
||||
#expect(payload.lastUsedAt == nil)
|
||||
} else {
|
||||
Issue.record("Expected an Identity payload")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyPendingIdentityUsageIsRemovedBeforeUpload() throws {
|
||||
let container = try makeContainer("LegacyPendingIdentityUsage")
|
||||
let context = container.mainContext
|
||||
let identity = Identity(
|
||||
name: "Deploy", username: "deploy",
|
||||
lastUsedAt: Date(timeIntervalSince1970: 700)
|
||||
)
|
||||
let envelope = VaultEnvelope(
|
||||
key: VaultRecordKey(kind: .identity, id: identity.id),
|
||||
version: .baseline(writerID: UUID()), payload: .identity(identity)
|
||||
)
|
||||
context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true))
|
||||
try context.save()
|
||||
|
||||
let journal = VaultSyncJournal(context: context)
|
||||
let outgoing = try #require(try journal.outgoingEntry(for: envelope.key))
|
||||
if case .identity(let payload) = outgoing.envelope.payload {
|
||||
#expect(payload.lastUsedAt == nil)
|
||||
} else {
|
||||
Issue.record("Expected an Identity payload")
|
||||
}
|
||||
#expect(try journal.pendingEnvelopes() == [outgoing.envelope])
|
||||
}
|
||||
|
||||
@Test
|
||||
func identityConfigurationEditsStillCreateVaultMutations() throws {
|
||||
let container = try makeContainer("IdentityConfigurationEdit")
|
||||
let persistence = HostPersistence(context: container.mainContext)
|
||||
var identity = Identity(
|
||||
name: "Deploy", username: "deploy",
|
||||
lastUsedAt: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
_ = try persistence.save(VaultSnapshot(identities: [identity]))
|
||||
identity.name = "Production Deploy"
|
||||
identity.lastUsedAt = Date(timeIntervalSince1970: 200)
|
||||
|
||||
let mutations = try persistence.save(VaultSnapshot(identities: [identity]))
|
||||
|
||||
#expect(mutations.keys == [VaultRecordKey(kind: .identity, id: identity.id)])
|
||||
let envelope = try #require(
|
||||
try VaultSyncJournal(context: container.mainContext).envelope(
|
||||
for: VaultRecordKey(kind: .identity, id: identity.id)
|
||||
)
|
||||
)
|
||||
if case .identity(let payload) = envelope.payload {
|
||||
#expect(payload.name == identity.name)
|
||||
#expect(payload.lastUsedAt == nil)
|
||||
} else {
|
||||
Issue.record("Expected an Identity payload")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func newerRemoteRecordWinsAndOlderRemoteRecordIsRequeued() throws {
|
||||
let container = try makeContainer("RemoteMerge")
|
||||
|
||||
@@ -2246,7 +2246,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -2283,7 +2283,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
||||
Reference in new issue
Block a user