From 0bf1144039e8a5583864cf12c99ab1b2ff2f9053 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 15 Sep 2026 12:45:14 +0800 Subject: [PATCH] Import identities from SSH folders --- .../FeatureModels/SSHImportModel.swift | 462 ++++++++++++++++-- .../SSHImport/SSHImportItem.swift | 13 +- Memory/ARCHITECTURE.md | 15 +- TFM/Features/SSHImport/SSHImportView.swift | 64 ++- TFMTests/SSHImportTests.swift | 145 +++++- 5 files changed, 630 insertions(+), 69 deletions(-) diff --git a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift index f84eec46..d2e33f36 100644 --- a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift @@ -9,6 +9,8 @@ public final class SSHImportModel { public var expanded = Set(SSHImportCategory.allCases) public var storage: KeyStorage = .iCloud public var keyColor: TagColor = .none + public var identityPasswordStorage: PasswordStorage = .iCloud + public var identityColor: TagColor = .none public var hostGroupID: HostGroup.ID? public var hostColor: TagColor = .none public var hostTags: [String] = [] @@ -24,6 +26,18 @@ public final class SSHImportModel { private var knownHostsOnly = false private let defaultUsername: String private var jumpIDs: [String: UUID] = [:] + private var stableIdentityIDs: [IdentitySignature: UUID] = [:] + private var itemIndexByID: [UUID: Int] = [:] + private var keyOptionsByHostID: [UUID: [SSHKeyRef]] = [:] + private var keyItemIDByKeyID: [UUID: UUID] = [:] + private var hostIDsByIdentityID: [UUID: [UUID]] = [:] + private var selectedHostNameCounts: [String: Int] = [:] + private var existingHostNames: Set = [] + + private struct IdentitySignature: Hashable { + var username: String + var keyID: UUID? + } public init(library: HostLibraryModel, reader: any SSHImportReading, defaultUsername: String = "") { self.defaultUsername = defaultUsername @@ -52,6 +66,7 @@ public final class SSHImportModel { error = nil defer { isWorking = false } do { + let previousItems = items let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) items = try await reader.scan(root: root, knownHostsOnly: knownHostsOnly, grants: grants) addJumpDestinations() @@ -64,6 +79,7 @@ public final class SSHImportModel { } } refreshDuplicates() + rebuildIdentityPlan(preserving: previousItems) } catch { self.error = (error as? SSHImportSourceError)?.localizedDescription @@ -72,9 +88,15 @@ public final class SSHImportModel { } private func addJumpDestinations() { - let originalIDs = items.filter { $0.host != nil }.map(\.id) + let originalIndexes = items.indices.filter { items[$0].host != nil } + var itemIndexByAlias: [String: Int] = [:] + for index in originalIndexes { + if let alias = items[index].host?.alias { itemIndexByAlias[alias] = index } + } + let savedHostsByName = Dictionary( + library.hosts.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) let jumps = items.compactMap(\.host).flatMap(\.jumps) - for jump in jumps where !items.contains(where: { $0.host?.alias == jump }) { + for jump in jumps where itemIndexByAlias[jump] == nil { let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init) let address = userParts.last ?? jump let host: String @@ -89,9 +111,9 @@ public final class SSHImportModel { host = address port = nil } - let saved = library.hosts.first { $0.name == host } + let saved = savedHostsByName[host] let identity = saved?.identityID.flatMap(library.identity) - let base = items.first { $0.host?.alias == host }?.host + let base = itemIndexByAlias[host].flatMap { items[$0].host } let value = SSHImportedHost( alias: jump, hostname: base?.hostname ?? saved?.host ?? host, port: port ?? base?.port ?? saved?.port ?? 22, @@ -100,17 +122,16 @@ public final class SSHImportModel { let id = jumpIDs[jump] ?? UUID() jumpIDs[jump] = id items.append(SSHImportItem(id: id, category: .hosts, name: jump, source: "ProxyJump", host: value)) + itemIndexByAlias[jump] = items.index(before: items.endIndex) } // A multi-hop route must not rewrite the standalone configuration of its intermediate aliases. - for id in originalIDs { - guard let index = items.firstIndex(where: { $0.id == id }), - let chain = items[index].host?.jumps, chain.count > 1 - else { continue } + for index in originalIndexes { + guard let chain = items[index].host?.jumps, chain.count > 1 else { continue } var previous = chain[0] for alias in chain.dropFirst() { - guard let base = items.first(where: { $0.host?.alias == alias }), var host = base.host else { continue } + guard let baseIndex = itemIndexByAlias[alias], var host = items[baseIndex].host else { continue } let routeAlias = previous + " → " + alias - if !items.contains(where: { $0.host?.alias == routeAlias }) { + if itemIndexByAlias[routeAlias] == nil { host.alias = routeAlias host.jumps = [previous] let id = jumpIDs[routeAlias] ?? UUID() @@ -118,7 +139,8 @@ public final class SSHImportModel { items.append( SSHImportItem( id: id, category: .hosts, name: routeAlias, - source: items[index].source, warnings: base.warnings, host: host)) + source: items[index].source, warnings: items[baseIndex].warnings, host: host)) + itemIndexByAlias[routeAlias] = items.index(before: items.endIndex) } previous = routeAlias } @@ -127,34 +149,45 @@ public final class SSHImportModel { } public func refreshDuplicates() { + for index in items.indices where items[index].category != .identities { + items[index].existingID = nil + } + rebuildItemIndex() + rebuildKeyOptionIndex() + let existingKeys = Dictionary( + library.keys.compactMap { key in + library.keyAvailability(for: key) == .available + ? (KeySignature(fingerprint: key.fingerprint, certificate: key.certificate), key.id) : nil + }, uniquingKeysWith: { first, _ in first }) + var seenKeys: [KeySignature: UUID] = [:] + var existingKnownHosts: [KnownHostSignature: UUID] = [:] + for entry in library.knownHosts { + existingKnownHosts[KnownHostSignature(entry), default: entry.id] = entry.id + } + var seenKnownHosts: [KnownHostSignature: UUID] = [:] for index in items.indices { guard !items[index].unavailable else { continue } if let key = items[index].key { - if let existing = library.keys.first(where: { - $0.fingerprint == key.fingerprint && $0.certificate == key.certificate - && library.keyAvailability(for: $0) == .available - }) { - items[index].existingID = existing.id + let signature = KeySignature(fingerprint: key.fingerprint, certificate: key.certificate) + if let existingID = existingKeys[signature] { + items[index].existingID = existingID items[index].selected = false - } else if let duplicate = items[.. Bool = { - $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) - && $0.identity == entry.identity - } - if let existing = library.knownHosts.first(where: same) { - items[index].existingID = existing.id + let signature = KnownHostSignature(entry) + if let existingID = existingKnownHosts[signature] { + items[index].existingID = existingID items[index].selected = false - } else if let previous = items[.. Bool { !item.unavailable && item.existingID == nil } @@ -173,15 +207,61 @@ public final class SSHImportModel { items.filter { (category == nil || $0.category == category) && selectable($0) } } public func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) { - var updated = items - for index in updated.indices - where selectable(updated[index]) && (category == nil || updated[index].category == category) { - updated[index].selected = selected + var changedIdentities: Set = [] + var deselectedKeyIDs: Set = [] + for index in items.indices + where selectable(items[index]) && (category == nil || items[index].category == category) { + items[index].selected = selected + if items[index].category == .identities { changedIdentities.insert(items[index].id) } + if !selected, let key = items[index].key { deselectedKeyIDs.insert(key.id) } } - items = updated + applyIdentityKeyDependencies(selected: selected, identityIDs: changedIdentities) + if !deselectedKeyIDs.isEmpty { + changedIdentities.formUnion(deselectIdentities(using: deselectedKeyIDs)) + } + rebuildHostNameIndex() + updateHostBindings(for: changedIdentities) + } + + public func setSelected(_ selected: Bool, itemID: UUID) { + guard let index = itemIndexByID[itemID], selectable(items[index]) else { return } + let category = items[index].category + let wasSelected = items[index].selected + items[index].selected = selected + if category == .hosts, wasSelected != selected { + selectedHostNameCounts[items[index].name, default: 0] += selected ? 1 : -1 + } + var changedIdentities: Set = category == .identities ? [itemID] : [] + if category == .identities, selected { + applyIdentityKeyDependencies(selected: true, identityIDs: changedIdentities) + } else if category == .keys, !selected, let keyID = items[index].key?.id { + changedIdentities.formUnion(deselectIdentities(using: [keyID])) + } + updateHostBindings(for: changedIdentities) + } + + public func isSelected(_ itemID: UUID) -> Bool { + itemIndexByID[itemID].map { items[$0].selected } ?? false + } + + public func item(_ itemID: UUID) -> SSHImportItem? { + itemIndexByID[itemID].map { items[$0] } + } + + public func updateItem(_ updated: SSHImportItem) { + guard let initialIndex = itemIndexByID[updated.id] else { return } + let current = items[initialIndex] + if current.selected != updated.selected { setSelected(updated.selected, itemID: updated.id) } + if current.preferredKeyID != updated.preferredKeyID { + setPreferredKey(updated.preferredKeyID, for: updated.id) + } + if current.name != updated.name { setName(updated.name, for: updated.id) } + guard let finalIndex = itemIndexByID[updated.id] else { return } + items[finalIndex].acceptedWarnings = updated.acceptedWarnings } public func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] { + if let cached = keyOptionsByHostID[item.id] { return cached } guard let host = item.host else { return [] } if let id = host.existingKeyID, let key = library.key(id) { return [key] } var seen: Set = [] @@ -198,7 +278,7 @@ public final class SSHImportModel { if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key } return nil } - return candidate.selected ? candidate.key : nil + return candidate.key } }.filter { seen.insert($0.id).inserted } } @@ -208,6 +288,275 @@ public final class SSHImportModel { return options.first { $0.id == item.preferredKeyID } ?? options.first } + public func setPreferredKey(_ keyID: UUID?, for hostID: UUID) { + guard let index = itemIndexByID[hostID], items[index].host != nil else { return } + let previousItems = items + items[index].preferredKeyID = keyID + rebuildIdentityPlan(preserving: previousItems, changedHostID: hostID) + } + + public func setName(_ name: String, for itemID: UUID) { + guard let index = itemIndexByID[itemID] else { return } + if items[index].category == .hosts, items[index].selected, selectable(items[index]) { + selectedHostNameCounts[items[index].name, default: 0] -= 1 + selectedHostNameCounts[name, default: 0] += 1 + } + items[index].name = name + } + + public func identityName(for hostItem: SSHImportItem) -> String? { + guard let identityID = hostItem.host?.identityID else { return nil } + if let index = itemIndexByID[identityID], items[index].identity != nil { + return items[index].name + } + return library.identity(identityID)?.name + } + + public func linkedHostNames(for identityItem: SSHImportItem) -> [String] { + (hostIDsByIdentityID[identityItem.id] ?? []).compactMap { id in + itemIndexByID[id].map { items[$0].name } + } + } + + public func keyName(_ id: UUID) -> String? { + library.key(id)?.label + ?? keyItemIDByKeyID[id].flatMap { itemIndexByID[$0] }.flatMap { items[$0].key?.label } + } + + private struct KeySignature: Hashable { + var fingerprint: String + var certificate: String + } + + private struct KnownHostSignature: Hashable { + var hostname: String + var port: Int + var identity: SSHHostKeyIdentity + + init(_ entry: KnownHostEntry) { + hostname = entry.hostname + port = entry.isHashed ? 0 : entry.port + identity = entry.identity + } + } + + /// Rebuilds public Identity previews from public key metadata. This never + /// reads private material. Dictionaries keep the scan linear in items plus + /// Host-to-key relationships instead of repeating array searches per row. + private func rebuildIdentityPlan(preserving previousItems: [SSHImportItem], changedHostID: UUID? = nil) { + let previousIdentities = Dictionary( + previousItems.compactMap { item -> (IdentitySignature, SSHImportItem)? in + guard let identity = item.identity else { return nil } + return (IdentitySignature(username: identity.username, keyID: identity.keyID), item) + }, uniquingKeysWith: { first, _ in first }) + let previousHostIdentitySelection: Bool? = changedHostID.flatMap { hostID in + guard let oldHost = previousItems.first(where: { $0.id == hostID }), + let identityID = oldHost.host?.identityID, + let identityItem = previousItems.first(where: { $0.id == identityID }) + else { return nil } + return identityItem.existingID != nil || identityItem.selected + } + + items.removeAll { $0.category == .identities } + rebuildItemIndex() + rebuildKeyOptionIndex() + + let existingIdentities = Dictionary( + library.identities.map { + (IdentitySignature(username: $0.username, keyID: $0.keyID), $0) + }, uniquingKeysWith: { first, _ in first }) + + var signatures: [IdentitySignature] = [] + var hostsBySignature: [IdentitySignature: [UUID]] = [:] + for item in items where item.host != nil { + guard let host = item.host else { continue } + let signature = IdentitySignature(username: host.username, keyID: chosenKey(for: item)?.id) + if hostsBySignature[signature] == nil { signatures.append(signature) } + hostsBySignature[signature, default: []].append(item.id) + } + + let signaturesPerUsername = Dictionary(grouping: signatures, by: \.username) + var usedNames: Set = Set(library.identities.map(\.name)) + var identityItems: [SSHImportItem] = [] + identityItems.reserveCapacity(signatures.count) + hostIDsByIdentityID = [:] + + for signature in signatures { + let hostIDs = hostsBySignature[signature] ?? [] + if let existing = existingIdentities[signature] { + identityItems.append( + SSHImportItem( + id: existing.id, category: .identities, name: existing.name, + source: identitySource(for: hostIDs), selected: false, + identity: existing, existingID: existing.id)) + hostIDsByIdentityID[existing.id] = hostIDs + continue + } + + let id = stableIdentityIDs[signature] ?? UUID() + stableIdentityIDs[signature] = id + let previous = previousIdentities[signature] + var name = + previous?.name + ?? defaultIdentityName( + for: signature, + hasAmbiguousUsername: (signaturesPerUsername[signature.username]?.count ?? 0) > 1, + usedNames: usedNames) + if name.isEmpty { name = signature.username } + usedNames.insert(name) + let identity = Identity( + id: id, name: name, username: signature.username, + passwordStorage: identityPasswordStorage, keyID: signature.keyID, + colorTag: identityColor) + let selected: Bool + if let previous { + selected = previous.selected + } else if let changedHostID, hostIDs.contains(changedHostID), let previousHostIdentitySelection { + selected = previousHostIdentitySelection + } else { + selected = true + } + identityItems.append( + SSHImportItem( + id: id, category: .identities, name: name, + source: identitySource(for: hostIDs), selected: selected, identity: identity)) + hostIDsByIdentityID[id] = hostIDs + } + + items.append(contentsOf: identityItems) + rebuildItemIndex() + updateAllHostBindings() + } + + private func rebuildKeyOptionIndex() { + keyOptionsByHostID = [:] + keyItemIDByKeyID = [:] + var canonicalKeysBySignature: [KeySignature: SSHKeyRef] = [:] + for key in library.keys where library.keyAvailability(for: key) == .available { + canonicalKeysBySignature[KeySignature(fingerprint: key.fingerprint, certificate: key.certificate)] = key + } + for item in items where item.category == .keys { + guard let key = item.key else { continue } + let signature = KeySignature(fingerprint: key.fingerprint, certificate: key.certificate) + if canonicalKeysBySignature[signature] == nil { canonicalKeysBySignature[signature] = key } + } + + var keyRowsBySource: [String: [(SSHImportItem, SSHKeyRef)]] = [:] + for item in items where item.category == .keys { + guard let key = item.key, + let canonical = canonicalKeysBySignature[ + KeySignature(fingerprint: key.fingerprint, certificate: key.certificate)] + else { continue } + keyRowsBySource[item.source, default: []].append((item, canonical)) + if library.key(canonical.id) == nil { keyItemIDByKeyID[canonical.id] = canonical.id } + } + + for item in items where item.host != nil { + guard let host = item.host else { continue } + if let keyID = host.existingKeyID, let key = library.key(keyID) { + keyOptionsByHostID[item.id] = [key] + continue + } + var seen: Set = [] + var options: [SSHKeyRef] = [] + for path in host.identityPaths { + for (candidate, key) in keyRowsBySource[path] ?? [] { + let matchesCertificate: Bool + if host.certificatePaths.isEmpty { + matchesCertificate = + candidate.certificatePaths.isEmpty + || candidate.certificatePaths.contains(path + "-cert.pub") + } else { + matchesCertificate = + !candidate.certificatePaths.isEmpty + && !Set(candidate.certificatePaths).isDisjoint(with: host.certificatePaths) + } + if matchesCertificate, seen.insert(key.id).inserted { options.append(key) } + } + } + keyOptionsByHostID[item.id] = options + } + } + + private func defaultIdentityName( + for signature: IdentitySignature, hasAmbiguousUsername: Bool, usedNames: Set + ) -> String { + guard hasAmbiguousUsername || usedNames.contains(signature.username) else { return signature.username } + let keyLabel = + signature.keyID.flatMap { keyID in + library.key(keyID)?.label + ?? keyItemIDByKeyID[keyID].flatMap { itemIndexByID[$0] }.flatMap { items[$0].key?.label } + } ?? String(localized: "No Key") + let base = "\(signature.username) (\(keyLabel))" + guard usedNames.contains(base), let keyID = signature.keyID, + let key = library.key(keyID) + ?? keyItemIDByKeyID[keyID].flatMap({ itemIndexByID[$0] }).flatMap({ items[$0].key }) + else { return base } + let shortFingerprint = key.fingerprint.split(separator: ":").last.map(String.init)?.prefix(8) ?? "" + return "\(base) \(shortFingerprint)" + } + + private func identitySource(for hostIDs: [UUID]) -> String { + hostIDs.compactMap { id in itemIndexByID[id].map { items[$0].name } }.joined(separator: ", ") + } + + private func rebuildItemIndex() { + itemIndexByID = Dictionary(uniqueKeysWithValues: items.indices.map { (items[$0].id, $0) }) + rebuildHostNameIndex() + } + + private func rebuildHostNameIndex() { + existingHostNames = Set(library.hosts.map(\.name)) + selectedHostNameCounts = [:] + for item in items where item.host != nil && item.selected && selectable(item) { + selectedHostNameCounts[item.name, default: 0] += 1 + } + } + + private func applyIdentityKeyDependencies(selected: Bool, identityIDs: Set) { + guard selected else { return } + for identityID in identityIDs { + guard let identityIndex = itemIndexByID[identityID], let keyID = items[identityIndex].identity?.keyID, + let keyItemID = keyItemIDByKeyID[keyID], let keyIndex = itemIndexByID[keyItemID], + selectable(items[keyIndex]) + else { continue } + items[keyIndex].selected = true + } + } + + private func deselectIdentities(using keyIDs: Set) -> Set { + var changed: Set = [] + for index in items.indices where items[index].category == .identities && items[index].existingID == nil { + guard let keyID = items[index].identity?.keyID, keyIDs.contains(keyID), items[index].selected else { + continue + } + items[index].selected = false + changed.insert(items[index].id) + } + return changed + } + + private func updateAllHostBindings() { + for identityID in hostIDsByIdentityID.keys { updateHostBindings(for: [identityID]) } + } + + private func updateHostBindings(for identityIDs: Set) { + for identityID in identityIDs { + guard let identityIndex = itemIndexByID[identityID] else { continue } + let identityItem = items[identityIndex] + let keyAvailable = + identityItem.identity?.keyID.map { keyID in + library.key(keyID) != nil + || keyItemIDByKeyID[keyID].flatMap { itemIndexByID[$0] }.map { items[$0].selected } == true + } ?? true + let available = identityItem.existingID != nil || (identityItem.selected && keyAvailable) + for hostID in hostIDsByIdentityID[identityID] ?? [] { + guard let hostIndex = itemIndexByID[hostID] else { continue } + items[hostIndex].host?.identityID = available ? identityID : nil + } + } + } + private func identicalHost(_ item: SSHImportItem) -> Host? { func matches(_ row: SSHImportItem, _ saved: Host, visited: Set) -> Bool { guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host, @@ -246,8 +595,8 @@ public final class SSHImportModel { public func hasNameConflict(_ item: SSHImportItem) -> Bool { guard item.host != nil else { return false } - return (library.hosts.contains { $0.name == item.name } && identicalHost(item) == nil) - || selectedItems(.hosts).contains { $0.id != item.id && $0.name == item.name } + return (existingHostNames.contains(item.name) && item.existingID == nil) + || selectedHostNameCounts[item.name, default: 0] > 1 } public func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? { @@ -258,6 +607,16 @@ public final class SSHImportModel { if includeWarningReminder && !warnings(for: item).isEmpty && !item.acceptedWarnings { return String(localized: "Review and accept the import warnings.") } + if let identity = item.identity { + guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return String(localized: "Invalid SSH record.") } + if let keyID = identity.keyID, library.key(keyID) == nil, + keyItemIDByKeyID[keyID].flatMap({ itemIndexByID[$0] }).map({ items[$0].selected }) != true + { + return String(localized: "Invalid SSH record.") + } + } if let host = item.host { guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !host.hostname.isEmpty, !host.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, @@ -300,6 +659,7 @@ public final class SSHImportModel { } items.replaceSubrange(index...index, with: replacements) refreshDuplicates() + rebuildIdentityPlan(preserving: items) } catch { if let sourceError = error as? SSHImportSourceError { self.error = sourceError.localizedDescription @@ -310,7 +670,9 @@ public final class SSHImportModel { } public func commit() async { + let beforeInitialRefresh = items refreshDuplicates() + rebuildIdentityPlan(preserving: beforeInitialRefresh) guard canImport else { return } isWorking = true error = nil @@ -319,7 +681,9 @@ public final class SSHImportModel { var batch = try makeBatch() batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id))) // Revalidate after awaiting the reader, because another window or sync can write meanwhile. + let beforeFinalRefresh = items refreshDuplicates() + rebuildIdentityPlan(preserving: beforeFinalRefresh) let refreshed = try makeBatch() guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)), selectedItems().allSatisfy({ problem(for: $0) == nil }) @@ -330,7 +694,7 @@ public final class SSHImportModel { batch.identities = refreshed.identities batch.knownHosts = refreshed.knownHosts try library.commitSSHImport(batch) - importedCount = batch.keys.count + batch.hosts.count + batch.knownHosts.count + importedCount = batch.keys.count + batch.identities.count + batch.hosts.count + batch.knownHosts.count completed = true await reader.discard() } catch { self.error = error.localizedDescription } @@ -347,6 +711,17 @@ public final class SSHImportModel { return key } batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost) + let selectedKeyIDs = Set(batch.keys.map(\.id)) + batch.identities = selectedItems(.identities).compactMap { item in + guard var identity = item.identity, + identity.keyID.map({ library.key($0) != nil || selectedKeyIDs.contains($0) }) ?? true + else { return nil } + identity.name = item.name + identity.passwordStorage = identityPasswordStorage + identity.colorTag = identityColor + return identity + } + let importedIdentityIDs = Set(batch.identities.map(\.id)) var built: [UUID: Host] = [:] var visiting: Set = [] func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host { @@ -372,19 +747,12 @@ public final class SSHImportModel { throw ImportRouteError.invalid } } - let keyID = chosenKey(for: item)?.id - let identity: Identity - if let existing = (library.identities + batch.identities).first(where: { - $0.username == imported.username && $0.keyID == keyID - }) { - identity = existing - } else { - identity = Identity(name: imported.username, username: imported.username, keyID: keyID) - batch.identities.append(identity) + let identityID = imported.identityID.flatMap { id in + library.identity(id) != nil || importedIdentityIDs.contains(id) ? id : nil } let host = Host( id: item.id, name: item.name, host: imported.hostname, port: imported.port, - identityID: identity.id, jumpHostID: jumpID, groupID: hostGroupID, + identityID: identityID, jumpHostID: jumpID, groupID: hostGroupID, tags: hostTags, colorTag: hostColor) built[item.id] = host return host diff --git a/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift b/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift index 806945c1..4781f4be 100644 --- a/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift +++ b/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift @@ -1,12 +1,13 @@ import Foundation public nonisolated enum SSHImportCategory: String, CaseIterable, Identifiable, Sendable { - case hosts, keys, knownHosts + case hosts, keys, identities, knownHosts public var id: Self { self } public var title: String { switch self { case .hosts: String(localized: "Hosts") case .keys: String(localized: "Keys") + case .identities: String(localized: "Identities") case .knownHosts: String(localized: "Known Hosts") } } @@ -28,6 +29,7 @@ public nonisolated struct SSHImportItem: Identifiable, Sendable { public var acceptedWarnings = false public var host: SSHImportedHost? public var key: SSHKeyRef? + public var identity: Identity? public var knownHost: KnownHostEntry? public var preferredKeyID: UUID? public var existingID: UUID? @@ -36,7 +38,8 @@ public nonisolated struct SSHImportItem: Identifiable, Sendable { id: UUID = UUID(), category: SSHImportCategory, name: String, source: String, selected: Bool = true, issue: String = "", unavailable: Bool = false, requiresPassphrase: Bool = false, needsAuthorization: String? = nil, warnings: [String] = [], acceptedWarnings: Bool = false, - host: SSHImportedHost? = nil, key: SSHKeyRef? = nil, knownHost: KnownHostEntry? = nil, + host: SSHImportedHost? = nil, key: SSHKeyRef? = nil, identity: Identity? = nil, + knownHost: KnownHostEntry? = nil, preferredKeyID: UUID? = nil, existingID: UUID? = nil, certificatePaths: [String] = [] ) { self.id = id @@ -52,6 +55,7 @@ public nonisolated struct SSHImportItem: Identifiable, Sendable { self.acceptedWarnings = acceptedWarnings self.host = host self.key = key + self.identity = identity self.knownHost = knownHost self.preferredKeyID = preferredKeyID self.existingID = existingID @@ -69,10 +73,14 @@ public nonisolated struct SSHImportedHost: Sendable { public var certificatePaths: [String] = [] public var jumps: [String] = [] public var existingKeyID: UUID? + /// The exact existing or selected imported Identity used by this Host. + /// `nil` deliberately means the Host will be imported without a login. + public var identityID: UUID? public var usesDefaultIdentityFiles = false public init( alias: String, hostname: String, port: Int = 22, username: String, identityPaths: [String] = [], certificatePaths: [String] = [], jumps: [String] = [], existingKeyID: UUID? = nil, + identityID: UUID? = nil, usesDefaultIdentityFiles: Bool = false ) { self.alias = alias @@ -83,6 +91,7 @@ public nonisolated struct SSHImportedHost: Sendable { self.certificatePaths = certificatePaths self.jumps = jumps self.existingKeyID = existingKeyID + self.identityID = identityID self.usesDefaultIdentityFiles = usesDefaultIdentityFiles } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index a315ddff..9e6c85db 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -71,12 +71,15 @@ change trust. Plain and OpenSSH `|1|` hashed endpoints share one matcher; hashed records keep their wire `hostname`, and their separate port field is ignored. SSH imports recursively classify files by content and stage only selectable public -metadata in a window-owned model. The actor reader retains scoped source descriptors -and content digests, never private-key text or passphrases; it rereads selected keys -transiently at unlock/commit and fails closed if a source changed. Picking and -drag-and-drop use the same scoped validation; config parsing never executes commands. -Commit writes new credentials before one Vault save, cleans them up on failure, never -overwrites existing records, and never clears an existing trust mismatch. +metadata in a window-owned model. Hosts, identities, and keys form an indexed preview: +an imported identity requires its selected key, while a host uses an exact existing or +selected imported identity and otherwise remains unbound. Selecting a host never selects +credentials. The actor reader retains scoped source descriptors and content digests, +never private-key text or passphrases; it rereads selected keys transiently at +unlock/commit and fails closed if a source changed. Picking and drag-and-drop use the +same scoped validation; config parsing never executes commands. Commit writes new +credentials before one Vault save, cleans them up on failure, never overwrites existing +records, and never clears an existing trust mismatch. App appearance is a global UserDefaults preference applied by the macOS app delegate through `NSApplication.appearance`; following System clears the override. diff --git a/TFM/Features/SSHImport/SSHImportView.swift b/TFM/Features/SSHImport/SSHImportView.swift index 43cd3c69..8402bff7 100644 --- a/TFM/Features/SSHImport/SSHImportView.swift +++ b/TFM/Features/SSHImport/SSHImportView.swift @@ -74,6 +74,16 @@ private struct SSHImportContent: View { Label("This Mac", systemImage: "lock.laptopcomputer").tag(KeyStorage.thisDevice) } } + Section("Identities") { + Picker("Store password in", selection: $model.identityPasswordStorage) { + ForEach(PasswordStorage.allCases) { storage in + Label(storage.displayName, systemImage: storage.symbol).tag(storage) + } + } + LabeledContent("Color") { + ColorTagPicker(selection: $model.identityColor, layout: .compact) + } + } } if !model.items.isEmpty { Section { @@ -233,10 +243,9 @@ private struct SSHImportContent: View { private func binding(for item: SSHImportItem) -> Binding { Binding { - model.items.first(where: { $0.id == item.id }) ?? item + model.item(item.id) ?? item } set: { updated in - guard let index = model.items.firstIndex(where: { $0.id == item.id }) else { return } - model.items[index] = updated + model.updateItem(updated) } } @@ -253,12 +262,18 @@ private struct SSHImportContent: View { let item = binding.wrappedValue return VStack(alignment: .leading, spacing: Metrics.Space.s) { HStack(alignment: .center, spacing: Metrics.Space.s) { - Toggle(item.name, isOn: binding.selected) - .labelsHidden() - .toggleStyle(.checkbox) - .fixedSize() - .accessibilityLabel(item.name) - .accessibilityHint(item.source) + Toggle( + item.name, + isOn: Binding( + get: { model.isSelected(item.id) }, + set: { model.setSelected($0, itemID: item.id) } + ) + ) + .labelsHidden() + .toggleStyle(.checkbox) + .fixedSize() + .accessibilityLabel(item.name) + .accessibilityHint(item.source) VStack(alignment: .leading) { Text(item.name).fontWeight(.medium).lineLimit(2) Text(sourceLabel(item.source)).font(.caption).foregroundStyle(.secondary).lineLimit(2).help( @@ -267,7 +282,7 @@ private struct SSHImportContent: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) .onTapGesture { - if model.selectable(item) { binding.selected.wrappedValue.toggle() } + if model.selectable(item) { model.setSelected(!model.isSelected(item.id), itemID: item.id) } } .accessibilityHidden(true) } @@ -283,7 +298,7 @@ private struct SSHImportContent: View { get: { binding.name.wrappedValue }, set: { editingNames.insert(item.id) - binding.name.wrappedValue = $0 + model.setName($0, for: item.id) } )) } @@ -292,12 +307,37 @@ private struct SSHImportContent: View { Picker( "Key", selection: Binding( - get: { model.chosenKey(for: item)?.id }, set: { binding.preferredKeyID.wrappedValue = $0 }) + get: { model.chosenKey(for: item)?.id }, + set: { model.setPreferredKey($0, for: item.id) }) ) { ForEach(options) { key in Text(key.label).tag(Optional(key.id)) } } } } + if item.host != nil { + LabeledContent("Identity", value: model.identityName(for: item) ?? String(localized: "None")) + } + if let identity = item.identity { + if model.selectable(item) { + TextField( + "Name", + text: Binding( + get: { binding.name.wrappedValue }, + set: { model.setName($0, for: item.id) } + )) + } + LabeledContent("Username", value: identity.username) + LabeledContent( + "Key", + value: identity.keyID.flatMap { id in + model.keyName(id) + } ?? String(localized: "No Key") + ) + let hosts = model.linkedHostNames(for: item) + if !hosts.isEmpty { + LabeledContent("Hosts", value: hosts.joined(separator: ", ")) + } + } if item.requiresPassphrase { SecureField( "Passphrase", diff --git a/TFMTests/SSHImportTests.swift b/TFMTests/SSHImportTests.swift index 96b4ba4f..4a98fcdb 100644 --- a/TFMTests/SSHImportTests.swift +++ b/TFMTests/SSHImportTests.swift @@ -181,6 +181,8 @@ struct SSHImportTests { #expect(source.contains("Form {")) #expect(source.contains(".formStyle(.grouped)")) #expect(source.contains("ForEach(model.items.filter")) + #expect(source.contains("Section(\"Identities\")")) + #expect(source.contains("Picker(\"Store password in\"")) #expect(!source.contains("private func categorySection")) } @@ -251,7 +253,7 @@ struct SSHImportTests { model.hostTags = ["production", "ssh"] model.keyColor = .purple await model.scan(url, knownHostsOnly: false) - #expect(model.selectedItems().count == 3) + #expect(model.selectedItems().count == 4) #expect(store.hosts.isEmpty && store.keys.isEmpty) #expect(model.canImport) await model.commit() @@ -289,10 +291,136 @@ struct SSHImportTests { let host = try #require(model.items.first { $0.host != nil }) #expect(model.chosenKey(for: host)?.id == id) model.setSelected(false, category: .keys) - #expect(model.chosenKey(for: host) == nil) + #expect(model.items.first(where: { $0.id == host.id })?.host?.identityID == nil) #expect(model.canImport) } + @Test + func identitySelectionControlsKeysAndHostBindingWithoutChangingHostSelection() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + try material.privateKey.write( + to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8) + try "Host app\n User deploy\n IdentityFile ~/.ssh/deploy".write( + to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) + + await model.scan(url, knownHostsOnly: false) + + let hostID = try #require(model.items.first(where: { $0.host != nil })?.id) + let identityID = try #require(model.items.first(where: { $0.identity != nil })?.id) + let keyID = try #require(model.items.first(where: { $0.key != nil })?.id) + #expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == identityID) + + model.setSelected(false, itemID: identityID) + #expect(model.items.first(where: { $0.id == hostID })?.selected == true) + #expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == nil) + #expect(model.items.first(where: { $0.id == keyID })?.selected == true) + + model.setSelected(false, itemID: hostID) + model.setSelected(true, itemID: hostID) + #expect(model.items.first(where: { $0.id == identityID })?.selected == false) + #expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == nil) + + model.setSelected(true, itemID: identityID) + #expect(model.items.first(where: { $0.id == keyID })?.selected == true) + #expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == identityID) + + model.setSelected(false, itemID: keyID) + #expect(model.items.first(where: { $0.id == identityID })?.selected == false) + #expect(model.items.first(where: { $0.id == hostID })?.selected == true) + #expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == nil) + } + + @Test + func sharedIdentityIsImportedOnceAndReceivesImportDefaults() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + try material.privateKey.write( + to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8) + try """ + Host app-one app-two + User deploy + IdentityFile ~/.ssh/deploy + """.write(to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let store = HostLibraryModel() + let model = SSHImportModel(library: store, reader: SSHImportReader()) + model.identityPasswordStorage = .thisDevice + model.identityColor = .orange + + await model.scan(url, knownHostsOnly: false) + + #expect(model.items.filter { $0.category == .identities }.count == 1) + await model.commit() + let identity = try #require(store.identities.first) + #expect(store.identities.count == 1) + #expect(store.hosts.count == 2) + #expect(store.hosts.allSatisfy { $0.identityID == identity.id }) + #expect(identity.passwordStorage == .thisDevice) + #expect(identity.colorTag == .orange) + #expect(!identity.usesPassword) + } + + @Test + func exactExistingIdentityIsReusedWhileDifferentKeysGetDistinctNames() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let first = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + let second = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + try first.privateKey.write(to: url.appendingPathComponent("first"), atomically: true, encoding: .utf8) + try second.privateKey.write(to: url.appendingPathComponent("second"), atomically: true, encoding: .utf8) + try """ + Host first + User deploy + IdentityFile ~/.ssh/first + Host second + User deploy + IdentityFile ~/.ssh/second + """.write(to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let existingKey = SSHKeyRef( + label: "Saved", publicKey: first.publicKey, fingerprint: first.fingerprint, + credentialRef: "saved") + let existingIdentity = Identity(name: "Existing Deploy", username: "deploy", keyID: existingKey.id) + let secrets = InMemorySecretStore() + try secrets.setKeyCredential( + .software(SSHPrivateKeySecret(privateKey: first.privateKey)), + for: existingKey.credentialRef, storage: existingKey.storage) + let store = HostLibraryModel(keys: [existingKey], identities: [existingIdentity], secrets: secrets) + let model = SSHImportModel(library: store, reader: SSHImportReader()) + + await model.scan(url, knownHostsOnly: false) + + let identities = model.items.filter { $0.category == .identities } + #expect(identities.count == 2) + #expect(identities.contains { $0.existingID == existingIdentity.id && $0.name == "Existing Deploy" }) + #expect(identities.contains { $0.existingID == nil && $0.name.contains("second") }) + } + + @Test + func indexedIdentityPlanningHandlesTheImportFileLimit() async { + let rows = (0..<4_096).map { index in + SSHImportItem( + category: .hosts, name: "host-\(index)", source: "config:\(index + 1)", + host: SSHImportedHost( + alias: "host-\(index)", hostname: "host-\(index).invalid", + username: "user-\(index)")) + } + let model = SSHImportModel(library: HostLibraryModel(), reader: FixtureSSHImportReader(items: rows)) + let start = ContinuousClock.now + + await model.scan(URL(fileURLWithPath: "/fixture/.ssh"), knownHostsOnly: false) + + #expect(start.duration(to: .now) < .seconds(2)) + #expect(model.items.filter { $0.category == .identities }.count == 4_096) + let hostID = rows[2_048].id + let selectionStart = ContinuousClock.now + model.setSelected(false, itemID: hostID) + #expect(selectionStart.duration(to: .now) < .milliseconds(100)) + #expect(model.items.first(where: { $0.id == hostID })?.selected == false) + } + @Test func jumpDependenciesAndCyclesBlockCommit() { let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) @@ -429,3 +557,16 @@ private final class FailingImportPersistence: VaultPersisting { throw CocoaError(.fileWriteUnknown) } } + +private actor FixtureSSHImportReader: SSHImportReading { + let items: [SSHImportItem] + + init(items: [SSHImportItem]) { + self.items = items + } + + func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) -> [SSHImportItem] { items } + func unlock(id: UUID, name: String, passphrase: String) -> [SSHImportItem] { [] } + func credentials(for ids: Set) -> [UUID: StoredSSHKeyCredential] { [:] } + func discard() {} +}