Import identities from SSH folders
This commit is contained in:
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
Reference in new issue
Block a user