809 lines
37 KiB
Swift
809 lines
37 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@Observable
|
|
public final class SSHImportModel {
|
|
public var items: [SSHImportItem] = []
|
|
public var expanded = Set(SSHImportCategory.allCases)
|
|
public var storage: PasswordStorage
|
|
public var importColor: TagColor = .none
|
|
public var hostGroupID: HostGroup.ID?
|
|
public var hostTags: [String] = []
|
|
public private(set) var isWorking = false
|
|
public private(set) var error: String?
|
|
public private(set) var completed = false
|
|
public private(set) var importedCount = 0
|
|
private let library: HostLibraryModel
|
|
private let reader: any SSHImportReading
|
|
private var root: URL?
|
|
private var grants: [URL] = []
|
|
public var sourcePath: String? { root?.path }
|
|
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<String> = []
|
|
|
|
private struct IdentitySignature: Hashable {
|
|
var username: String
|
|
var keyID: UUID?
|
|
}
|
|
|
|
public init(
|
|
library: HostLibraryModel,
|
|
reader: any SSHImportReading,
|
|
defaultUsername: String = "",
|
|
storage: PasswordStorage = .iCloud
|
|
) {
|
|
self.defaultUsername = defaultUsername
|
|
self.library = library
|
|
self.reader = reader
|
|
self.storage = storage
|
|
}
|
|
|
|
public func scan(_ url: URL, knownHostsOnly: Bool) async {
|
|
guard !isWorking else { return }
|
|
grants = []
|
|
items = []
|
|
completed = false
|
|
root = url
|
|
self.knownHostsOnly = knownHostsOnly
|
|
await reload()
|
|
}
|
|
|
|
public func authorize(_ url: URL) async {
|
|
grants.append(url)
|
|
await reload()
|
|
}
|
|
|
|
private func reload() async {
|
|
guard let root else { return }
|
|
isWorking = true
|
|
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()
|
|
for index in items.indices {
|
|
if let old = previous[items[index].id] {
|
|
items[index].selected = old.selected
|
|
items[index].acceptedWarnings = old.acceptedWarnings
|
|
items[index].preferredKeyID = old.preferredKeyID
|
|
if items[index].host != nil { items[index].name = old.name }
|
|
}
|
|
}
|
|
refreshDuplicates()
|
|
rebuildIdentityPlan(preserving: previousItems)
|
|
} catch {
|
|
self.error =
|
|
(error as? SSHImportSourceError)?.localizedDescription
|
|
?? String(localized: "The SSH files could not be read.")
|
|
}
|
|
}
|
|
|
|
private func addJumpDestinations() {
|
|
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 itemIndexByAlias[jump] == nil {
|
|
let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init)
|
|
let address = userParts.last ?? jump
|
|
let host: String
|
|
let port: Int?
|
|
if address.hasPrefix("["), let endpoint = SSHImportParser.endpoint(address) {
|
|
(host, port) = endpoint
|
|
} else if address.filter({ $0 == ":" }).count == 1 {
|
|
let parts = address.split(separator: ":").map(String.init)
|
|
host = parts.first ?? address
|
|
port = parts.count == 2 ? Int(parts[1]) ?? 0 : 0
|
|
} else {
|
|
host = address
|
|
port = nil
|
|
}
|
|
let saved = savedHostsByName[host]
|
|
let identity = saved?.identityID.flatMap(library.identity)
|
|
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,
|
|
username: userParts.count == 2 ? userParts[0] : base?.username ?? identity?.username ?? defaultUsername,
|
|
identityPaths: base?.identityPaths ?? [], existingKeyID: identity?.keyID)
|
|
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 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 baseIndex = itemIndexByAlias[alias], var host = items[baseIndex].host else { continue }
|
|
let routeAlias = previous + " → " + alias
|
|
if itemIndexByAlias[routeAlias] == nil {
|
|
host.alias = routeAlias
|
|
host.jumps = [previous]
|
|
let id = jumpIDs[routeAlias] ?? UUID()
|
|
jumpIDs[routeAlias] = id
|
|
items.append(
|
|
SSHImportItem(
|
|
id: id, category: .hosts, name: routeAlias,
|
|
source: items[index].source, warnings: items[baseIndex].warnings, host: host))
|
|
itemIndexByAlias[routeAlias] = items.index(before: items.endIndex)
|
|
}
|
|
previous = routeAlias
|
|
}
|
|
items[index].host?.jumps = [previous]
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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 duplicateID = seenKeys[signature] {
|
|
items[index].existingID = duplicateID
|
|
items[index].selected = false
|
|
}
|
|
seenKeys[signature] = items[index].existingID ?? items[index].id
|
|
}
|
|
if let entry = items[index].knownHost {
|
|
let signature = KnownHostSignature(entry)
|
|
if let existingID = existingKnownHosts[signature] {
|
|
items[index].existingID = existingID
|
|
items[index].selected = false
|
|
} else if let duplicateID = seenKnownHosts[signature] {
|
|
items[index].existingID = duplicateID
|
|
items[index].selected = false
|
|
}
|
|
seenKnownHosts[signature] = items[index].existingID ?? items[index].id
|
|
}
|
|
}
|
|
for index in items.indices where items[index].host != nil {
|
|
if let existing = identicalHost(items[index]) {
|
|
items[index].existingID = existing.id
|
|
items[index].selected = false
|
|
}
|
|
}
|
|
rebuildItemIndex()
|
|
}
|
|
|
|
public func selectable(_ item: SSHImportItem) -> Bool { !item.unavailable && item.existingID == nil }
|
|
public func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
|
|
items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected }
|
|
}
|
|
public func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
|
|
items.filter { (category == nil || $0.category == category) && selectable($0) }
|
|
}
|
|
public func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) {
|
|
var changedIdentities: Set<UUID> = []
|
|
var deselectedKeyIDs: Set<UUID> = []
|
|
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) }
|
|
}
|
|
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<UUID> = 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<UUID> = []
|
|
return host.identityPaths.flatMap { path in
|
|
items.filter {
|
|
guard $0.source == path && $0.category == .keys else { return false }
|
|
if host.certificatePaths.isEmpty {
|
|
return $0.certificatePaths.isEmpty || $0.certificatePaths.contains(path + "-cert.pub")
|
|
}
|
|
return !$0.certificatePaths.isEmpty && !Set($0.certificatePaths).isDisjoint(with: host.certificatePaths)
|
|
}.compactMap { candidate -> SSHKeyRef? in
|
|
if let id = candidate.existingID {
|
|
if let key = library.key(id) { return key }
|
|
if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key }
|
|
return nil
|
|
}
|
|
return candidate.key
|
|
}
|
|
}.filter { seen.insert($0.id).inserted }
|
|
}
|
|
|
|
public func chosenKey(for item: SSHImportItem) -> SSHKeyRef? {
|
|
let options = keyOptions(for: item)
|
|
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.reusableIdentities.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<String> = Set(library.reusableIdentities.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: storage, keyID: signature.keyID,
|
|
colorTag: importColor)
|
|
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<UUID> = []
|
|
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>
|
|
) -> 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<UUID>) {
|
|
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<UUID>) -> Set<UUID> {
|
|
var changed: Set<UUID> = []
|
|
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<UUID>) {
|
|
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<UUID>) -> Bool {
|
|
guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host,
|
|
saved.name == row.name && saved.host == candidate.hostname && saved.port == candidate.port,
|
|
let identity = saved.identityID.flatMap(library.identity), identity.username == candidate.username,
|
|
identity.keyID == chosenKey(for: row)?.id
|
|
else { return false }
|
|
if candidate.jumps.isEmpty { return saved.jumpHostID == nil }
|
|
guard candidate.jumps.count == 1, let alias = candidate.jumps.first,
|
|
let jump = saved.jumpHostID.flatMap(library.host)
|
|
else { return false }
|
|
if let next = items.first(where: { $0.host?.alias == alias }) {
|
|
return matches(next, jump, visited: visited.union([saved.id]))
|
|
}
|
|
return jump.name == alias
|
|
}
|
|
return library.hosts.first { matches(item, $0, visited: []) }
|
|
}
|
|
|
|
public func warnings(for item: SSHImportItem) -> [String] {
|
|
var warnings = item.warnings
|
|
if let entry = item.knownHost,
|
|
library.knownHosts.contains(where: {
|
|
let sameAddress =
|
|
$0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port)
|
|
|| (!entry.isHashed && $0.matches(hostname: entry.hostname, port: entry.port))
|
|
|| (!$0.isHashed && entry.matches(hostname: $0.hostname, port: $0.port))
|
|
return sameAddress && $0.identity != entry.identity
|
|
})
|
|
{
|
|
warnings.append(
|
|
String(localized: "This address already has a different trusted fingerprint. Confirm adding this key."))
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
public func hasNameConflict(_ item: SSHImportItem) -> Bool {
|
|
guard item.host != nil else { return false }
|
|
return (existingHostNames.contains(item.name) && item.existingID == nil)
|
|
|| selectedHostNameCounts[item.name, default: 0] > 1
|
|
}
|
|
|
|
public func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? {
|
|
if !item.issue.isEmpty { return item.issue }
|
|
if item.requiresPassphrase {
|
|
return String(localized: "Enter the key passphrase to validate this private key.")
|
|
}
|
|
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,
|
|
(1...65_535).contains(host.port)
|
|
else { return String(localized: "Invalid SSH record.") }
|
|
if hasNameConflict(item) {
|
|
return String(localized: "A different host already uses this name. Rename this import or deselect it.")
|
|
}
|
|
for jump in host.jumps {
|
|
if !library.hosts.contains(where: { $0.name == jump })
|
|
&& !items.contains(where: { $0.host?.alias == jump && ($0.selected || $0.existingID != nil) })
|
|
{
|
|
return String(localized: "Select the required jump host or deselect this host.")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public var canImport: Bool {
|
|
!isWorking && !completed && !selectedItems().isEmpty
|
|
&& selectedItems().allSatisfy { problem(for: $0) == nil }
|
|
&& (try? makeBatch()) != nil
|
|
}
|
|
|
|
public func unlock(_ id: UUID, passphrase: String) async {
|
|
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
|
isWorking = true
|
|
error = nil
|
|
defer { isWorking = false }
|
|
do {
|
|
let previous = items[index]
|
|
var replacements = try await reader.unlock(id: id, name: previous.name, passphrase: passphrase)
|
|
guard !replacements.isEmpty, replacements.allSatisfy({ $0.category == .keys && $0.key != nil }) else {
|
|
throw CocoaError(.coderInvalidValue)
|
|
}
|
|
for replacementIndex in replacements.indices {
|
|
replacements[replacementIndex].selected = previous.selected
|
|
replacements[replacementIndex].acceptedWarnings = previous.acceptedWarnings
|
|
}
|
|
items.replaceSubrange(index...index, with: replacements)
|
|
refreshDuplicates()
|
|
rebuildIdentityPlan(preserving: items)
|
|
} catch {
|
|
if let sourceError = error as? SSHImportSourceError {
|
|
self.error = sourceError.localizedDescription
|
|
} else {
|
|
items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func commit() async {
|
|
let beforeInitialRefresh = items
|
|
refreshDuplicates()
|
|
rebuildIdentityPlan(preserving: beforeInitialRefresh)
|
|
guard canImport else { return }
|
|
isWorking = true
|
|
error = nil
|
|
defer { isWorking = false }
|
|
do {
|
|
var batch = try makeBatch()
|
|
try await library.resolveSavingHostsAccess(batch.hosts)
|
|
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 })
|
|
else {
|
|
throw CocoaError(.coderInvalidValue)
|
|
}
|
|
batch.hosts = refreshed.hosts
|
|
batch.identities = refreshed.identities
|
|
batch.knownHosts = refreshed.knownHosts
|
|
try library.commitSSHImport(batch)
|
|
importedCount = batch.keys.count + batch.identities.count + batch.hosts.count + batch.knownHosts.count
|
|
completed = true
|
|
await reader.discard()
|
|
} catch HostAccessError.hostLimit {
|
|
// The host quota presents the Paywall without replacing the import selection with an error.
|
|
self.error = nil
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
|
|
public func discard() async { await reader.discard() }
|
|
|
|
private func makeBatch() throws -> SSHImportBatch {
|
|
var batch = SSHImportBatch()
|
|
batch.keys = selectedItems(.keys).compactMap { item in
|
|
guard var key = item.key else { return nil }
|
|
key.storage =
|
|
switch storage {
|
|
case .thisDevice: .thisDevice
|
|
case .iCloud: .iCloud
|
|
}
|
|
key.colorTag = importColor
|
|
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 = storage
|
|
identity.colorTag = importColor
|
|
return identity
|
|
}
|
|
let importedIdentityIDs = Set(batch.identities.map(\.id))
|
|
var built: [UUID: Host] = [:]
|
|
var visiting: Set<UUID> = []
|
|
func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host {
|
|
if let existing = item.existingID.flatMap(library.host) { return existing }
|
|
if let existing = built[item.id] {
|
|
if let forcedJump, existing.jumpHostID != forcedJump { throw ImportRouteError.conflictingChain }
|
|
return existing
|
|
}
|
|
guard visiting.insert(item.id).inserted, visiting.count <= 8, let imported = item.host else {
|
|
throw ImportRouteError.invalid
|
|
}
|
|
defer { visiting.remove(item.id) }
|
|
var jumpID = forcedJump
|
|
for alias in imported.jumps {
|
|
if let candidate = items.first(where: {
|
|
$0.host?.alias == alias && ($0.selected || $0.existingID != nil)
|
|
}) {
|
|
jumpID = try build(candidate, forcedJump: jumpID).id
|
|
} else if let existing = library.hosts.first(where: { $0.name == alias }) {
|
|
if let jumpID, existing.jumpHostID != jumpID { throw ImportRouteError.conflictingChain }
|
|
jumpID = existing.id
|
|
} else {
|
|
throw ImportRouteError.invalid
|
|
}
|
|
}
|
|
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: identityID, jumpHostID: jumpID, groupID: hostGroupID,
|
|
tags: hostTags, colorTag: importColor)
|
|
built[item.id] = host
|
|
return host
|
|
}
|
|
// Build routes with the most hops first so intermediate aliases get their chain before reuse.
|
|
for item in selectedItems(.hosts).sorted(by: { ($0.host?.jumps.count ?? 0) > ($1.host?.jumps.count ?? 0) }) {
|
|
_ = try build(item)
|
|
}
|
|
batch.hosts = built.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
|
let all = Dictionary(uniqueKeysWithValues: (library.hosts + batch.hosts).map { ($0.id, $0) })
|
|
for host in batch.hosts {
|
|
var current: Host? = host
|
|
var seen: Set<UUID> = []
|
|
while let node = current {
|
|
guard seen.insert(node.id).inserted, seen.count <= 8 else { throw ImportRouteError.invalid }
|
|
current = node.jumpHostID.flatMap { all[$0] }
|
|
}
|
|
}
|
|
return batch
|
|
}
|
|
|
|
public var routeProblem: String? {
|
|
do {
|
|
_ = try makeBatch()
|
|
return nil
|
|
} catch { return error.localizedDescription }
|
|
}
|
|
}
|
|
|
|
nonisolated private enum ImportRouteError: LocalizedError {
|
|
case invalid, conflictingChain
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalid: String(localized: "The imported jump route is missing, cyclic, or exceeds 8 hops.")
|
|
case .conflictingChain:
|
|
String(localized: "These hosts require conflicting jump routes. Import the routes separately.")
|
|
}
|
|
}
|
|
}
|