Import identities from SSH folders

This commit is contained in:
Kaede Akino committed 2026-09-15 12:45:14 +08:00
1 parent 9b1514c3bc
commit 0bf1144039
5 files changed
+630 -69

No files matched your search

@@ -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<String> = []
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[..<index].first(where: {
$0.key?.fingerprint == key.fingerprint && $0.key?.certificate == key.certificate
}) {
items[index].existingID = duplicate.existingID ?? duplicate.id
} 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 same: (KnownHostEntry) -> 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[..<index].first(where: { $0.knownHost.map(same) == true }) {
items[index].existingID = previous.existingID ?? previous.id
} 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 {
@@ -163,6 +196,7 @@ public final class SSHImportModel {
items[index].selected = false
}
}
rebuildItemIndex()
}
public func selectable(_ item: SSHImportItem) -> 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<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) }
}
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<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> = []
@@ -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<String> = 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<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,
@@ -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<UUID> = []
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
@@ -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
}
+9 -6
View File
@@ -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.
+52 -12
View File
@@ -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<SSHImportItem> {
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",
+143 -2
View File
@@ -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>) -> [UUID: StoredSSHKeyCredential] { [:] }
func discard() {}
}