Add Known Hosts manager and SSH folder import
This commit is contained in:
1 parent
f0fdcf4ddb
commit
afa77ae16a
33 files changed
+2848
-388
No files matched your search
+14
-4
@@ -38,10 +38,20 @@ including that no type AgentKit owns is redefined here.
|
||||
- The shell always starts with `Vault` and `Assistant`; Settings is the final Navigator
|
||||
page after the dynamic SSH/SFTP Workspaces.
|
||||
- Vault owns its navigation column, including quick-connect Recents, and routes
|
||||
for Hosts, Keychain, Snippets, Welcome, and Host Editor. These routes never
|
||||
for Hosts, Keychain, Snippets, Known Hosts, Welcome, and Host Editor. These routes never
|
||||
enter the Workspace deck.
|
||||
- Known Hosts management belongs to the Host Editor draft for both new and
|
||||
existing Hosts. Context-panel Host details are read-only.
|
||||
- Known Hosts is an independent Vault card manager below Snippets. Host edits and
|
||||
deletion never move or remove trust records. Plain endpoints and OpenSSH `|1|`
|
||||
hashed endpoints use one matcher across all SSH callers. Hashed tokens retain
|
||||
their original `hostname` wire field; their `port` field is ignored because the
|
||||
port is part of the hashed input. No Host record is required to retain trust.
|
||||
- SSH folder imports stage selectable public metadata in a window-owned model;
|
||||
their actor-owned reader retains private keys until commit or cancellation.
|
||||
File picking and native drag-and-drop feed the same source validation and preview.
|
||||
File access is scoped to explicitly selected directories/files. Config parsing
|
||||
never executes commands. New credentials are written before a single Vault
|
||||
snapshot save and removed on failure. Existing records are never overwritten.
|
||||
Imported trust does not clear existing mismatch warnings.
|
||||
- Assistant owns its conversation list through `AssistantLibraryModel`; conversations are
|
||||
not panes and do not participate in Workspace drag, split, close, or reconnect
|
||||
lifecycles.
|
||||
@@ -73,7 +83,7 @@ an honest unknown outcome. Duration is never inferred from client receive time,
|
||||
because SSH buffering can deliver start and completion events together.
|
||||
|
||||
Feature views receive aggregate-facing Application Models rather than a generic
|
||||
store API: `HostLibraryModel`, `CredentialLibraryModel`, `SnippetLibraryModel`,
|
||||
store API: `HostLibraryModel`, `CredentialLibraryModel`, `KnownHostsLibraryModel`, `SnippetLibraryModel`,
|
||||
`ForwardingLibraryModel`, `TransferLibraryModel`, `AIConfigurationModel`,
|
||||
`SkillLibraryModel`, `CommandHistoryModel`, and `ToolAuditModel`. Aggregates that share one persisted
|
||||
vault snapshot still expose separate intent surfaces, so a feature cannot gain
|
||||
|
||||
@@ -52,6 +52,7 @@ final class AppModel {
|
||||
private let hosts: HostLibraryModel
|
||||
let proAccess: ProAccessController
|
||||
var hostLibrary: HostLibraryModel { hosts }
|
||||
let knownHosts: KnownHostsLibraryModel
|
||||
let credentials: CredentialLibraryModel
|
||||
let snippets: SnippetLibraryModel
|
||||
let forwardings: ForwardingLibraryModel
|
||||
@@ -112,6 +113,10 @@ final class AppModel {
|
||||
/// conversation, and every run re-arms the hook that would do it.
|
||||
private var titledConversationIDs = Set<UUID>()
|
||||
|
||||
func makeSSHImportModel() -> SSHImportModel {
|
||||
SSHImportModel(library: hosts, reader: SSHImportReader())
|
||||
}
|
||||
|
||||
init(
|
||||
hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(),
|
||||
skills: SkillLibraryModel = SkillLibraryModel(),
|
||||
@@ -140,6 +145,7 @@ final class AppModel {
|
||||
self.hosts = hosts
|
||||
self.proAccess = proAccess
|
||||
self.onboarding = onboarding
|
||||
knownHosts = KnownHostsLibraryModel(library: hosts)
|
||||
credentials = CredentialLibraryModel(library: hosts)
|
||||
snippets = SnippetLibraryModel(library: hosts)
|
||||
forwardings = ForwardingLibraryModel(library: hosts)
|
||||
|
||||
@@ -250,7 +250,7 @@ final class HostLibraryModel {
|
||||
/// hostname plus port. Newest keys are shown first in the host's details.
|
||||
func knownHosts(for host: Host) -> [KnownHostEntry] {
|
||||
knownHosts
|
||||
.filter { $0.hostname == host.host && $0.port == host.port }
|
||||
.filter { $0.matches(hostname: host.host, port: host.port) }
|
||||
.sorted {
|
||||
if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt }
|
||||
if $0.fingerprint != $1.fingerprint { return $0.fingerprint < $1.fingerprint }
|
||||
@@ -631,8 +631,7 @@ final class HostLibraryModel {
|
||||
@discardableResult
|
||||
func upsert(
|
||||
_ host: Host,
|
||||
replacingPortForwardings rules: [PortForwardingRule],
|
||||
replacingKnownHosts knownHostEntries: [KnownHostEntry]? = nil
|
||||
replacingPortForwardings rules: [PortForwardingRule]
|
||||
) -> Bool {
|
||||
guard validatedRoute(for: host) != nil else {
|
||||
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
|
||||
@@ -654,34 +653,8 @@ final class HostLibraryModel {
|
||||
return false
|
||||
}
|
||||
|
||||
let normalizedKnownHosts = knownHostEntries.map { entries in
|
||||
entries.map { entry in
|
||||
var normalized = entry
|
||||
normalized.hostname = host.host
|
||||
normalized.port = host.port
|
||||
normalized.fingerprint = entry.fingerprint.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
if let normalizedKnownHosts {
|
||||
let identities = normalizedKnownHosts.map(\.identity)
|
||||
guard
|
||||
normalizedKnownHosts.allSatisfy({
|
||||
!$0.fingerprint.isEmpty
|
||||
}),
|
||||
Set(normalizedKnownHosts.map(\.id)).count == normalizedKnownHosts.count,
|
||||
Set(identities).count == identities.count
|
||||
else {
|
||||
storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let oldHosts = hosts
|
||||
let oldRules = portForwardings
|
||||
let oldKnownHosts = knownHosts
|
||||
let existingHost = self.host(host.id)
|
||||
let existingRules = portForwardings(for: host.id)
|
||||
let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) })
|
||||
@@ -704,20 +677,9 @@ final class HostLibraryModel {
|
||||
}
|
||||
portForwardings.removeAll { $0.hostID == host.id }
|
||||
portForwardings.append(contentsOf: normalizedRules)
|
||||
if let normalizedKnownHosts {
|
||||
if let existingHost {
|
||||
knownHosts.removeAll {
|
||||
$0.hostname == existingHost.host && $0.port == existingHost.port
|
||||
}
|
||||
}
|
||||
knownHosts.removeAll { $0.hostname == host.host && $0.port == host.port }
|
||||
knownHosts.append(contentsOf: normalizedKnownHosts)
|
||||
}
|
||||
|
||||
guard persistCore() else {
|
||||
hosts = oldHosts
|
||||
portForwardings = oldRules
|
||||
knownHosts = oldKnownHosts
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -812,18 +774,16 @@ final class HostLibraryModel {
|
||||
/// first connection, which is the whole point — it is the escape hatch for a
|
||||
/// server that was legitimately rekeyed.
|
||||
func forget(knownHost id: KnownHostEntry.ID) {
|
||||
knownHosts.removeAll { $0.id == id }
|
||||
persistCore()
|
||||
deleteKnownHosts([id])
|
||||
}
|
||||
|
||||
func forgetKnownHosts(for host: Host) {
|
||||
knownHosts.removeAll { $0.hostname == host.host && $0.port == host.port }
|
||||
persistCore()
|
||||
deleteKnownHosts(Set(knownHosts(for: host).map(\.id)))
|
||||
}
|
||||
|
||||
func trust(_ entry: KnownHostEntry) {
|
||||
let endpointMatches: (KnownHostEntry) -> Bool = {
|
||||
$0.hostname == entry.hostname && $0.port == entry.port
|
||||
$0.matches(hostname: entry.hostname, port: entry.port)
|
||||
}
|
||||
for index in knownHosts.indices where endpointMatches(knownHosts[index]) {
|
||||
knownHosts[index].isMismatched = false
|
||||
@@ -847,7 +807,7 @@ final class HostLibraryModel {
|
||||
func markKnownHostMismatched(hostname: String, port: Int) {
|
||||
var changed = false
|
||||
for index in knownHosts.indices
|
||||
where knownHosts[index].hostname == hostname && knownHosts[index].port == port {
|
||||
where knownHosts[index].matches(hostname: hostname, port: port) {
|
||||
knownHosts[index].isMismatched = true
|
||||
changed = true
|
||||
}
|
||||
@@ -1514,6 +1474,111 @@ final class HostLibraryModel {
|
||||
persistCore()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveKnownHost(_ entry: KnownHostEntry) -> Bool {
|
||||
var entry = entry
|
||||
entry.hostname = entry.hostname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
entry.fingerprint = entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard entry.isValid,
|
||||
!knownHosts.contains(where: {
|
||||
$0.id != entry.id && $0.hostname == entry.hostname && $0.port == entry.port
|
||||
&& $0.identity == entry.identity
|
||||
})
|
||||
else {
|
||||
storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.")
|
||||
return false
|
||||
}
|
||||
let previous = knownHosts
|
||||
if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) {
|
||||
// Editing the address or key explicitly creates a new trust decision.
|
||||
if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname
|
||||
|| knownHosts[index].port != entry.port
|
||||
{
|
||||
entry.addedAt = Date()
|
||||
entry.isMismatched = false
|
||||
}
|
||||
knownHosts[index] = entry
|
||||
} else {
|
||||
entry.addedAt = Date()
|
||||
knownHosts.append(entry)
|
||||
}
|
||||
guard persistCore() else {
|
||||
knownHosts = previous
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deleteKnownHosts(_ ids: Set<UUID>) -> Bool {
|
||||
let previous = knownHosts
|
||||
knownHosts.removeAll { ids.contains($0.id) }
|
||||
guard persistCore() else {
|
||||
knownHosts = previous
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// New records only. Existing credentials and host configurations are never overwritten.
|
||||
func commitSSHImport(_ batch: SSHImportBatch) throws {
|
||||
let oldHosts = hosts
|
||||
let oldIdentities = identities
|
||||
let oldKeys = keys
|
||||
let oldKnownHosts = knownHosts
|
||||
guard Set(batch.hosts.map(\.id)).isDisjoint(with: hosts.map(\.id)),
|
||||
Set(batch.keys.map(\.id)).isDisjoint(with: keys.map(\.id)),
|
||||
Set(batch.identities.map(\.id)).isDisjoint(with: identities.map(\.id)),
|
||||
batch.knownHosts.allSatisfy(\.isValid)
|
||||
else { throw CocoaError(.validationMultipleErrors) }
|
||||
var written: [SSHKeyRef] = []
|
||||
do {
|
||||
for key in batch.keys {
|
||||
guard let secret = batch.credentials[key.id] else {
|
||||
throw CocoaError(.validationMissingMandatoryProperty)
|
||||
}
|
||||
try keychain.setKeyCredential(secret, for: key.credentialRef, storage: key.storage)
|
||||
written.append(key)
|
||||
}
|
||||
for var key in batch.keys {
|
||||
key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1
|
||||
keys.append(key)
|
||||
}
|
||||
for var identity in batch.identities {
|
||||
identity.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1
|
||||
identities.append(identity)
|
||||
}
|
||||
for var host in batch.hosts {
|
||||
host.sortIndex = nextSortIndex(in: nil)
|
||||
hosts.append(host)
|
||||
}
|
||||
guard batch.hosts.allSatisfy({ validatedRoute(for: $0) != nil }) else {
|
||||
throw CocoaError(.validationMultipleErrors)
|
||||
}
|
||||
knownHosts.append(contentsOf: batch.knownHosts)
|
||||
guard persistCore() else {
|
||||
throw TermindSSHError.library(storageError ?? String(localized: "Import failed."))
|
||||
}
|
||||
} catch {
|
||||
hosts = oldHosts
|
||||
identities = oldIdentities
|
||||
keys = oldKeys
|
||||
knownHosts = oldKnownHosts
|
||||
var cleanupFailed = false
|
||||
for key in written {
|
||||
do { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } catch {
|
||||
cleanupFailed = true
|
||||
}
|
||||
}
|
||||
if cleanupFailed {
|
||||
storageError = String(
|
||||
localized: "Import failed. Some newly stored keys could not be removed from Keychain.")
|
||||
throw TermindSSHError.library(storageError!)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func nextSortIndex(in group: HostGroup.ID?) -> Double {
|
||||
(hosts(in: group).last?.sortIndex ?? 0) + 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class KnownHostsLibraryModel {
|
||||
private let library: HostLibraryModel
|
||||
init(library: HostLibraryModel) { self.library = library }
|
||||
var entries: [KnownHostEntry] { library.knownHosts.sorted { $0.addedAt > $1.addedAt } }
|
||||
var storageError: String? { library.storageError }
|
||||
func entry(_ id: UUID) -> KnownHostEntry? { entries.first { $0.id == id } }
|
||||
func search(_ query: String) -> [KnownHostEntry] {
|
||||
entries.filter {
|
||||
query.isEmpty || $0.displayAddress.localizedCaseInsensitiveContains(query)
|
||||
|| $0.fingerprint.localizedCaseInsensitiveContains(query)
|
||||
|| $0.algorithm.rawValue.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
func save(_ entry: KnownHostEntry) -> Bool { library.saveKnownHost(entry) }
|
||||
func delete(_ ids: Set<UUID>) -> Bool { library.deleteKnownHosts(ids) }
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class SSHImportModel {
|
||||
var items: [SSHImportItem] = []
|
||||
var expanded = Set(SSHImportCategory.allCases)
|
||||
var storage: KeyStorage = .iCloud
|
||||
private(set) var isWorking = false
|
||||
private(set) var error: String?
|
||||
private(set) var completed = false
|
||||
private(set) var importedCount = 0
|
||||
private let library: HostLibraryModel
|
||||
private let reader: any SSHImportReading
|
||||
private var root: URL?
|
||||
private var grants: [URL] = []
|
||||
var sourcePath: String? { root?.path }
|
||||
private var knownHostsOnly = false
|
||||
private var jumpIDs: [String: UUID] = [:]
|
||||
|
||||
init(library: HostLibraryModel, reader: any SSHImportReading) {
|
||||
self.library = library
|
||||
self.reader = reader
|
||||
}
|
||||
|
||||
func scan(_ url: URL, knownHostsOnly: Bool) async {
|
||||
guard !isWorking else { return }
|
||||
grants = []
|
||||
items = []
|
||||
completed = false
|
||||
root = url
|
||||
self.knownHostsOnly = knownHostsOnly
|
||||
await reload()
|
||||
}
|
||||
|
||||
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 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()
|
||||
} catch {
|
||||
self.error =
|
||||
(error as? SSHImportSourceError)?.localizedDescription
|
||||
?? String(localized: "The SSH files could not be read.")
|
||||
}
|
||||
}
|
||||
|
||||
private func addJumpDestinations() {
|
||||
let originalIDs = items.filter { $0.host != nil }.map(\.id)
|
||||
let jumps = items.compactMap(\.host).flatMap(\.jumps)
|
||||
for jump in jumps where !items.contains(where: { $0.host?.alias == jump }) {
|
||||
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 = library.hosts.first { $0.name == host }
|
||||
let identity = saved?.identityID.flatMap(library.identity)
|
||||
let base = items.first { $0.host?.alias == host }?.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 ?? NSUserName(),
|
||||
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))
|
||||
}
|
||||
// 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 }
|
||||
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 }
|
||||
let routeAlias = previous + " → " + alias
|
||||
if !items.contains(where: { $0.host?.alias == routeAlias }) {
|
||||
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: base.warnings, host: host))
|
||||
}
|
||||
previous = routeAlias
|
||||
}
|
||||
items[index].host?.jumps = [previous]
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDuplicates() {
|
||||
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
|
||||
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
|
||||
items[index].selected = false
|
||||
}
|
||||
}
|
||||
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
|
||||
items[index].selected = false
|
||||
} else if let previous = items[..<index].first(where: { $0.knownHost.map(same) == true }) {
|
||||
items[index].existingID = previous.existingID ?? previous.id
|
||||
items[index].selected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectable(_ item: SSHImportItem) -> Bool { !item.unavailable && item.existingID == nil }
|
||||
func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
|
||||
items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected }
|
||||
}
|
||||
func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
|
||||
items.filter { (category == nil || $0.category == category) && selectable($0) }
|
||||
}
|
||||
func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) {
|
||||
for index in items.indices
|
||||
where selectable(items[index]) && (category == nil || items[index].category == category) {
|
||||
items[index].selected = selected
|
||||
}
|
||||
}
|
||||
|
||||
func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] {
|
||||
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.selected ? candidate.key : nil
|
||||
}
|
||||
}.filter { seen.insert($0.id).inserted }
|
||||
}
|
||||
|
||||
func chosenKey(for item: SSHImportItem) -> SSHKeyRef? {
|
||||
let options = keyOptions(for: item)
|
||||
return options.first { $0.id == item.preferredKeyID } ?? options.first
|
||||
}
|
||||
|
||||
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: []) }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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 host = item.host {
|
||||
guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!host.hostname.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
|
||||
}
|
||||
|
||||
var canImport: Bool {
|
||||
!isWorking && !completed && !selectedItems().isEmpty
|
||||
&& selectedItems().allSatisfy { problem(for: $0) == nil }
|
||||
&& (try? makeBatch()) != nil
|
||||
}
|
||||
|
||||
func unlock(_ id: UUID, passphrase: String) async {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
items[index].key = try await reader.unlock(id: id, name: items[index].name, passphrase: passphrase)
|
||||
items[index].requiresPassphrase = false
|
||||
items[index].issue = ""
|
||||
refreshDuplicates()
|
||||
} catch {
|
||||
items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.")
|
||||
}
|
||||
}
|
||||
|
||||
func commit() async {
|
||||
refreshDuplicates()
|
||||
guard canImport else { return }
|
||||
isWorking = true
|
||||
error = nil
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
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.
|
||||
refreshDuplicates()
|
||||
let refreshed = try makeBatch()
|
||||
guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)),
|
||||
selectedItems().allSatisfy({ problem(for: $0) == nil })
|
||||
else {
|
||||
throw CocoaError(.validationMultipleErrors)
|
||||
}
|
||||
batch.hosts = refreshed.hosts
|
||||
batch.identities = refreshed.identities
|
||||
batch.knownHosts = refreshed.knownHosts
|
||||
try library.commitSSHImport(batch)
|
||||
importedCount = batch.keys.count + batch.hosts.count + batch.knownHosts.count
|
||||
completed = true
|
||||
await reader.discard()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
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 = storage
|
||||
return key
|
||||
}
|
||||
batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost)
|
||||
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 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 host = Host(
|
||||
id: item.id, name: item.name, host: imported.hostname, port: imported.port,
|
||||
identityID: identity.id, jumpHostID: jumpID)
|
||||
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
|
||||
}
|
||||
|
||||
var routeProblem: String? {
|
||||
do {
|
||||
_ = try makeBatch()
|
||||
return nil
|
||||
} catch { return error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private enum ImportRouteError: LocalizedError {
|
||||
case invalid, conflictingChain
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// Execution-only payload. Secrets are never part of the observable import preview.
|
||||
nonisolated struct SSHImportBatch: Sendable {
|
||||
var hosts: [Host] = []
|
||||
var identities: [Identity] = []
|
||||
var keys: [SSHKeyRef] = []
|
||||
var knownHosts: [KnownHostEntry] = []
|
||||
var credentials: [UUID: StoredSSHKeyCredential] = [:]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated protocol SSHImportReading: Sendable {
|
||||
func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) async throws -> [SSHImportItem]
|
||||
func unlock(id: UUID, name: String, passphrase: String) async throws -> SSHKeyRef
|
||||
func credentials(for ids: Set<UUID>) async throws -> [UUID: StoredSSHKeyCredential]
|
||||
func discard() async
|
||||
}
|
||||
@@ -23,3 +23,27 @@ nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
nonisolated extension KnownHostEntry {
|
||||
var isHashed: Bool { hostname.hasPrefix("|") }
|
||||
|
||||
var displayAddress: String {
|
||||
isHashed ? hostname : Host(name: "", host: hostname, port: port).displayAddress
|
||||
}
|
||||
|
||||
func matches(hostname candidate: String, port candidatePort: Int) -> Bool {
|
||||
if isHashed {
|
||||
return SSHKnownHostMatcher.matches(hash: hostname, hostname: candidate, port: candidatePort)
|
||||
}
|
||||
return hostname.caseInsensitiveCompare(candidate) == .orderedSame && port == candidatePort
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
let addressValid =
|
||||
isHashed
|
||||
? SSHKnownHostMatcher.isValidHash(hostname)
|
||||
: !hostname.isEmpty && !hostname.contains(where: { $0.isWhitespace || "*,!?|[]".contains($0) })
|
||||
&& (1...65_535).contains(port)
|
||||
return addressValid && !fingerprint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
nonisolated enum SSHKnownHostMatcher {
|
||||
static func isValidHash(_ hash: String) -> Bool { components(hash) != nil }
|
||||
|
||||
static func matches(hash: String, hostname: String, port: Int) -> Bool {
|
||||
guard let (salt, expected) = components(hash) else { return false }
|
||||
let address = port == 22 ? hostname.lowercased() : "[\(hostname.lowercased())]:\(port)"
|
||||
let actual = HMAC<Insecure.SHA1>.authenticationCode(
|
||||
for: Data(address.utf8), using: SymmetricKey(data: salt)
|
||||
)
|
||||
return Data(actual) == expected
|
||||
}
|
||||
|
||||
private static func components(_ hash: String) -> (Data, Data)? {
|
||||
let parts = hash.split(separator: "|", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4, parts[0].isEmpty, parts[1] == "1",
|
||||
let salt = Data(base64Encoded: String(parts[2])), salt.count == 20,
|
||||
let digest = Data(base64Encoded: String(parts[3])), digest.count == 20
|
||||
else { return nil }
|
||||
return (salt, digest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
|
||||
/// Evaluates each concrete alias against the original ordered file tree.
|
||||
/// Includes share the current Host/Match scope, just as textual configuration does.
|
||||
nonisolated struct SSHConfigParser {
|
||||
let root: URL
|
||||
let home: URL
|
||||
let username: String
|
||||
let files: [String: String]
|
||||
var externalPaths: Set<String> = []
|
||||
var diagnostics: [SSHImportItem] = []
|
||||
|
||||
func expandedPath(_ path: String, host: SSHImportedHost? = nil) -> String {
|
||||
var value = path
|
||||
let replacements = [
|
||||
"%d": home.path, "%u": username, "%h": host?.hostname ?? "",
|
||||
"%n": host?.alias ?? "", "%r": host?.username ?? username,
|
||||
"%p": String(host?.port ?? 22),
|
||||
]
|
||||
value = value.replacingOccurrences(of: "%%", with: "\u{1}")
|
||||
for (token, replacement) in replacements { value = value.replacingOccurrences(of: token, with: replacement) }
|
||||
value = value.replacingOccurrences(of: "\u{1}", with: "%")
|
||||
if value == "~/.ssh" {
|
||||
value = root.path
|
||||
} else if value.hasPrefix("~/.ssh/") {
|
||||
value = root.appendingPathComponent(String(value.dropFirst(7))).path
|
||||
} else if value.hasPrefix("~/") {
|
||||
value = home.appendingPathComponent(String(value.dropFirst(2))).path
|
||||
}
|
||||
if !value.hasPrefix("/") { value = root.appendingPathComponent(value).path }
|
||||
return URL(fileURLWithPath: value).standardizedFileURL.path
|
||||
}
|
||||
|
||||
mutating func parse() -> [SSHImportItem] {
|
||||
let config = root.appendingPathComponent("config").path
|
||||
guard files[config] != nil else { return [] }
|
||||
var aliases: [String] = []
|
||||
discover(config, stack: [], aliases: &aliases)
|
||||
var result: [SSHImportItem] = []
|
||||
for alias in aliases {
|
||||
var state = Evaluation()
|
||||
state.warnings = diagnostics.map { $0.source + ": " + $0.issue }
|
||||
evaluate(config, alias: alias, stack: [], state: &state)
|
||||
var host = SSHImportedHost(
|
||||
alias: alias, hostname: state.values["hostname"] ?? alias, username: state.values["user"] ?? username)
|
||||
host.hostname = host.hostname.replacingOccurrences(of: "%h", with: alias).replacingOccurrences(
|
||||
of: "%n", with: alias)
|
||||
var issue = ""
|
||||
if let value = state.values["port"] {
|
||||
if let port = Int(value), (1...65_535).contains(port) {
|
||||
host.port = port
|
||||
} else {
|
||||
issue = String(localized: "Invalid SSH port.")
|
||||
}
|
||||
}
|
||||
let identities =
|
||||
state.identities.isEmpty
|
||||
? ["~/.ssh/id_rsa", "~/.ssh/id_ecdsa", "~/.ssh/id_ed25519"] : state.identities
|
||||
host.usesDefaultIdentityFiles = state.identities.isEmpty
|
||||
host.identityPaths = identities.filter { $0 != "none" }.map { expandedPath($0, host: host) }
|
||||
host.certificatePaths = state.certificates.filter { $0 != "none" }.map { expandedPath($0, host: host) }
|
||||
if let jump = state.values["proxyjump"], jump != "none" {
|
||||
host.jumps = jump.split(separator: ",").map(String.init)
|
||||
}
|
||||
let source = state.source.isEmpty ? config : state.source
|
||||
result.append(
|
||||
SSHImportItem(
|
||||
category: .hosts, name: alias, source: source,
|
||||
issue: issue, warnings: state.warnings, host: host))
|
||||
}
|
||||
return result + diagnostics
|
||||
}
|
||||
|
||||
private struct Evaluation {
|
||||
var active = true
|
||||
var inMatch = false
|
||||
var values: [String: String] = [:]
|
||||
var identities: [String] = []
|
||||
var certificates: [String] = []
|
||||
var warnings: [String] = []
|
||||
var source = ""
|
||||
}
|
||||
|
||||
private mutating func lines(_ path: String) -> [SSHConfigLine] {
|
||||
guard let content = files[path] else {
|
||||
externalPaths.insert(path)
|
||||
return []
|
||||
}
|
||||
return content.components(separatedBy: .newlines).enumerated().compactMap { index, raw in
|
||||
do {
|
||||
let tokens = try SSHImportParser.tokens(raw)
|
||||
guard let keyword = tokens.first else { return nil }
|
||||
guard tokens.count > 1 else { throw ImportParseError.invalid }
|
||||
return SSHConfigLine(
|
||||
keyword: keyword.lowercased(), values: Array(tokens.dropFirst()), source: path, line: index + 1)
|
||||
} catch {
|
||||
let location = "\(path):\(index + 1)"
|
||||
if !diagnostics.contains(where: { $0.source == location }) {
|
||||
diagnostics.append(
|
||||
SSHImportItem(
|
||||
category: .hosts, name: URL(fileURLWithPath: path).lastPathComponent,
|
||||
source: location, selected: false, issue: String(localized: "Invalid SSH record."),
|
||||
unavailable: true))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func includes(_ line: SSHConfigLine) -> [String] {
|
||||
line.values.flatMap { value -> [String] in
|
||||
let pattern = expandedPath(value)
|
||||
if pattern.contains("*") || pattern.contains("?") {
|
||||
let matches = files.keys.filter { SSHImportParser.glob(pattern, matches: $0, path: true) }.sorted()
|
||||
if matches.isEmpty && !pattern.hasPrefix(root.path + "/") { externalPaths.insert(pattern) }
|
||||
return matches
|
||||
}
|
||||
if files[pattern] == nil { externalPaths.insert(pattern) }
|
||||
return [pattern]
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func discover(_ path: String, stack: [String], aliases: inout [String]) {
|
||||
guard !stack.contains(path), stack.count < 32 else {
|
||||
diagnostics.append(
|
||||
SSHImportItem(
|
||||
category: .hosts, name: "Include", source: path, selected: false,
|
||||
issue: String(localized: "Include cycle or nesting limit reached."), unavailable: true))
|
||||
return
|
||||
}
|
||||
for line in lines(path) {
|
||||
if line.keyword == "host" {
|
||||
for alias in line.values where SSHImportParser.concrete(alias) && !aliases.contains(alias) {
|
||||
aliases.append(alias)
|
||||
}
|
||||
} else if line.keyword == "include" {
|
||||
for file in includes(line) { discover(file, stack: stack + [path], aliases: &aliases) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func evaluate(_ path: String, alias: String, stack: [String], state: inout Evaluation) {
|
||||
guard !stack.contains(path), stack.count < 32 else {
|
||||
state.warnings.append(String(localized: "Include cycle or nesting limit reached."))
|
||||
return
|
||||
}
|
||||
for line in lines(path) {
|
||||
let location = "\(line.source):\(line.line)"
|
||||
if line.keyword == "host" {
|
||||
state.active = SSHImportParser.matches(line.values, alias: alias)
|
||||
state.inMatch = false
|
||||
if line.values.contains(alias) && state.source.isEmpty { state.source = location }
|
||||
continue
|
||||
}
|
||||
if line.keyword == "match" {
|
||||
state.inMatch = true
|
||||
state.active = false
|
||||
state.warnings.append("\(location): Match")
|
||||
continue
|
||||
}
|
||||
guard state.active && !state.inMatch else { continue }
|
||||
if line.keyword == "include" {
|
||||
for file in includes(line) {
|
||||
if files[file] == nil { state.warnings.append("\(location): Include \(file)") }
|
||||
evaluate(file, alias: alias, stack: stack + [path], state: &state)
|
||||
}
|
||||
} else if ["hostname", "port", "user", "proxyjump"].contains(line.keyword) {
|
||||
if state.values[line.keyword] == nil { state.values[line.keyword] = line.values.first }
|
||||
} else if line.keyword == "identityfile" {
|
||||
if let value = line.values.first, !state.identities.contains(value) { state.identities.append(value) }
|
||||
} else if line.keyword == "certificatefile" {
|
||||
if let value = line.values.first { state.certificates.append(value) }
|
||||
} else {
|
||||
state.warnings.append("\(location): \(line.keyword)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated enum SSHImportCategory: String, CaseIterable, Identifiable, Sendable {
|
||||
case hosts, keys, knownHosts
|
||||
var id: Self { self }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .hosts: String(localized: "Hosts")
|
||||
case .keys: String(localized: "Keys")
|
||||
case .knownHosts: String(localized: "Known Hosts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public metadata only. The reader retains private material behind item IDs.
|
||||
nonisolated struct SSHImportItem: Identifiable, Sendable {
|
||||
var id = UUID()
|
||||
var category: SSHImportCategory
|
||||
var name: String
|
||||
var source: String
|
||||
var selected = true
|
||||
var issue: String = ""
|
||||
var unavailable = false
|
||||
var requiresPassphrase = false
|
||||
var needsAuthorization: String?
|
||||
var warnings: [String] = []
|
||||
var acceptedWarnings = false
|
||||
var host: SSHImportedHost?
|
||||
var key: SSHKeyRef?
|
||||
var knownHost: KnownHostEntry?
|
||||
var preferredKeyID: UUID?
|
||||
var existingID: UUID?
|
||||
var certificatePaths: [String] = []
|
||||
}
|
||||
|
||||
nonisolated struct SSHImportedHost: Sendable {
|
||||
var alias: String
|
||||
var hostname: String
|
||||
var port = 22
|
||||
var username: String
|
||||
var identityPaths: [String] = []
|
||||
var certificatePaths: [String] = []
|
||||
var jumps: [String] = []
|
||||
var existingKeyID: UUID?
|
||||
var usesDefaultIdentityFiles = false
|
||||
}
|
||||
|
||||
nonisolated struct SSHConfigLine: Sendable {
|
||||
var keyword: String
|
||||
var values: [String]
|
||||
var source: String
|
||||
var line: Int
|
||||
}
|
||||
|
||||
nonisolated enum SSHImportSourceError: LocalizedError, Equatable {
|
||||
case expectedFile, expectedFolder
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .expectedFile: String(localized: "Choose or drop a single known_hosts file.")
|
||||
case .expectedFolder: String(localized: "Choose or drop a single .ssh folder.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
nonisolated enum SSHImportParser {
|
||||
/// Tokenizes OpenSSH's quoted arguments without invoking a shell.
|
||||
static func tokens(_ line: String) throws -> [String] {
|
||||
var result: [String] = []
|
||||
var token = ""
|
||||
var quote: Character?
|
||||
var escaping = false
|
||||
for character in line {
|
||||
if escaping {
|
||||
token.append(character)
|
||||
escaping = false
|
||||
continue
|
||||
}
|
||||
if character == "\\" {
|
||||
escaping = true
|
||||
continue
|
||||
}
|
||||
if let current = quote {
|
||||
if character == current { quote = nil } else { token.append(character) }
|
||||
} else if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
} else if character == "#", token.isEmpty {
|
||||
break
|
||||
} else if character.isWhitespace
|
||||
|| (character == "=" && (result.isEmpty || (result.count == 1 && token.isEmpty)))
|
||||
{
|
||||
if !token.isEmpty {
|
||||
result.append(token)
|
||||
token = ""
|
||||
}
|
||||
} else {
|
||||
token.append(character)
|
||||
}
|
||||
}
|
||||
guard quote == nil, !escaping else { throw CocoaError(.fileReadCorruptFile) }
|
||||
if !token.isEmpty { result.append(token) }
|
||||
return result
|
||||
}
|
||||
|
||||
static func glob(_ pattern: String, matches value: String, path: Bool = false) -> Bool {
|
||||
let expression =
|
||||
"^"
|
||||
+ NSRegularExpression.escapedPattern(for: pattern)
|
||||
.replacingOccurrences(of: "\\*", with: path ? "[^/]*" : ".*").replacingOccurrences(
|
||||
of: "\\?", with: path ? "[^/]" : ".") + "$"
|
||||
return value.range(of: expression, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
|
||||
static func matches(_ patterns: [String], alias: String) -> Bool {
|
||||
var positive = false
|
||||
for pattern in patterns {
|
||||
if pattern.hasPrefix("!") {
|
||||
if glob(String(pattern.dropFirst()), matches: alias) { return false }
|
||||
} else if glob(pattern, matches: alias) {
|
||||
positive = true
|
||||
}
|
||||
}
|
||||
return positive
|
||||
}
|
||||
|
||||
static func concrete(_ alias: String) -> Bool {
|
||||
!alias.isEmpty && !alias.contains(where: { "*?!".contains($0) })
|
||||
}
|
||||
|
||||
static func endpoint(_ value: String) -> (String, Int)? {
|
||||
if value.hasPrefix("[") {
|
||||
guard let close = value.firstIndex(of: "]"),
|
||||
value[value.index(after: close)...].hasPrefix(":"),
|
||||
let port = Int(value[value.index(close, offsetBy: 2)...]), (1...65_535).contains(port)
|
||||
else { return nil }
|
||||
return (String(value[value.index(after: value.startIndex)..<close]), port)
|
||||
}
|
||||
guard !value.isEmpty else { return nil }
|
||||
return (value, 22)
|
||||
}
|
||||
|
||||
static func knownHosts(_ text: String, source: String) -> [SSHImportItem] {
|
||||
var result: [SSHImportItem] = []
|
||||
for (offset, raw) in text.components(separatedBy: .newlines).enumerated() {
|
||||
let fields = raw.split(whereSeparator: \.isWhitespace).map(String.init)
|
||||
guard let first = fields.first, !first.hasPrefix("#") else { continue }
|
||||
let location = "\(source):\(offset + 1)"
|
||||
do {
|
||||
guard !first.hasPrefix("@") else { throw ImportParseError.unsupported }
|
||||
guard fields.count >= 3, let algorithm = SSHKeyAlgorithm(rawValue: fields[1]),
|
||||
let blob = Data(base64Encoded: fields[2]), validPublicKey(blob, algorithm: algorithm)
|
||||
else { throw ImportParseError.invalid }
|
||||
let fingerprint =
|
||||
"SHA256:"
|
||||
+ Data(SHA256.hash(data: blob)).base64EncodedString()
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
let addresses = first.split(separator: ",", omittingEmptySubsequences: false).map(String.init)
|
||||
// Negation belongs to the whole pattern list, not to one split entry.
|
||||
guard !addresses.contains(where: { $0.contains(where: { "*!?".contains($0) }) }) else {
|
||||
throw ImportParseError.unsupported
|
||||
}
|
||||
for address in addresses {
|
||||
let endpoint = address.hasPrefix("|") ? (address, 22) : endpoint(address)
|
||||
guard let (hostname, port) = endpoint else { throw ImportParseError.invalid }
|
||||
let entry = KnownHostEntry(
|
||||
hostname: hostname, port: port, algorithm: algorithm, fingerprint: fingerprint, addedAt: Date()
|
||||
)
|
||||
guard entry.isValid else { throw ImportParseError.invalid }
|
||||
result.append(
|
||||
SSHImportItem(
|
||||
category: .knownHosts, name: entry.displayAddress, source: location, knownHost: entry
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
result.append(
|
||||
SSHImportItem(
|
||||
category: .knownHosts, name: first, source: location, selected: false,
|
||||
issue: error.localizedDescription, unavailable: true
|
||||
))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Validate the wire key structure as well as its outer algorithm label.
|
||||
static func validPublicKey(_ blob: Data, algorithm: SSHKeyAlgorithm) -> Bool {
|
||||
var cursor = 0
|
||||
func field() -> Data? {
|
||||
guard cursor + 4 <= blob.count else { return nil }
|
||||
let length = blob[cursor..<cursor + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
cursor += 4
|
||||
guard Int(length) <= blob.count - cursor else { return nil }
|
||||
defer { cursor += Int(length) }
|
||||
return blob.subdata(in: cursor..<cursor + Int(length))
|
||||
}
|
||||
guard let name = field(), String(data: name, encoding: .utf8) == algorithm.rawValue else { return false }
|
||||
switch algorithm {
|
||||
case .ed25519:
|
||||
guard field()?.count == 32 else { return false }
|
||||
case .rsa:
|
||||
guard let exponent = field(), !exponent.isEmpty, let modulus = field(), !modulus.isEmpty else {
|
||||
return false
|
||||
}
|
||||
case .ecdsa256, .ecdsa384, .ecdsa521:
|
||||
let curve = algorithm.rawValue.replacingOccurrences(of: "ecdsa-sha2-", with: "")
|
||||
guard let name = field(), String(data: name, encoding: .utf8) == curve,
|
||||
let point = field(), point.first == 4,
|
||||
point.count == (algorithm == .ecdsa256 ? 65 : algorithm == .ecdsa384 ? 97 : 133)
|
||||
else { return false }
|
||||
}
|
||||
return cursor == blob.count
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated enum ImportParseError: LocalizedError {
|
||||
case invalid, unsupported
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalid: String(localized: "Invalid SSH record.")
|
||||
case .unsupported: String(localized: "This SSH record is not supported.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ struct HostEditorView: View {
|
||||
|
||||
@State private var draft = Host(name: "", host: "")
|
||||
@State private var draftForwardings: [PortForwardingRule] = []
|
||||
@State private var draftKnownHosts: [KnownHostEntry] = []
|
||||
@State private var originalKnownHosts: [KnownHostEntry] = []
|
||||
@State private var route: Route?
|
||||
@State private var saveError: String?
|
||||
@State private var didLoad = false
|
||||
@@ -161,8 +159,6 @@ struct HostEditorView: View {
|
||||
openEditor: { route = .forwarding($0) }
|
||||
)
|
||||
|
||||
KnownHostsEditorSection(entries: $draftKnownHosts)
|
||||
|
||||
Section("Group & Tags") {
|
||||
Picker("Group", selection: groupChoice) {
|
||||
Text("None").tag(Choice<HostGroup.ID>.unset)
|
||||
@@ -455,26 +451,12 @@ struct HostEditorView: View {
|
||||
|| draft.sftpSessionInitialPath.hasPrefix("/"))
|
||||
&& app.hostLibrary.validatedRoute(for: draft) != nil
|
||||
&& draftForwardings.allSatisfy(\.isValid)
|
||||
&& knownHostsAreValid
|
||||
}
|
||||
|
||||
private var knownHostsAreValid: Bool {
|
||||
let normalized = draftKnownHosts.map { entry in
|
||||
SSHHostKeyIdentity(
|
||||
algorithm: entry.algorithm,
|
||||
fingerprint: entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
return normalized.allSatisfy { !$0.fingerprint.isEmpty }
|
||||
&& Set(normalized).count == normalized.count
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let hostID, let existing = app.hostLibrary.host(hostID) else { return }
|
||||
draft = existing
|
||||
draftForwardings = app.hostLibrary.portForwardings(for: hostID)
|
||||
originalKnownHosts = app.hostLibrary.knownHosts(for: existing)
|
||||
draftKnownHosts = originalKnownHosts
|
||||
}
|
||||
|
||||
private func loadOnce() {
|
||||
@@ -494,8 +476,7 @@ struct HostEditorView: View {
|
||||
for index in draftForwardings.indices { draftForwardings[index].hostID = draft.id }
|
||||
if app.hostLibrary.upsert(
|
||||
draft,
|
||||
replacingPortForwardings: draftForwardings,
|
||||
replacingKnownHosts: knownHostsForSave
|
||||
replacingPortForwardings: draftForwardings
|
||||
) {
|
||||
closeTab()
|
||||
} else {
|
||||
@@ -503,193 +484,8 @@ struct HostEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var knownHostsForSave: [KnownHostEntry] {
|
||||
let originals = Dictionary(uniqueKeysWithValues: originalKnownHosts.map { ($0.id, $0) })
|
||||
return draftKnownHosts.map { entry in
|
||||
var normalized = entry
|
||||
normalized.fingerprint = entry.fingerprint.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
if let original = originals[entry.id],
|
||||
original.algorithm == normalized.algorithm,
|
||||
original.fingerprint == normalized.fingerprint
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
normalized.addedAt = Date()
|
||||
normalized.isMismatched = false
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
/// Return to Hosts so the saved or retained record is immediately visible.
|
||||
private func closeTab() {
|
||||
window.openPage(.hosts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Known Hosts is part of the Host draft, including during creation. Nothing is
|
||||
/// trusted or forgotten until the editor's Save commits the entire collection.
|
||||
private struct KnownHostsEditorSection: View {
|
||||
@Binding var entries: [KnownHostEntry]
|
||||
@State private var newAlgorithm: SSHKeyAlgorithm = .ed25519
|
||||
@State private var newFingerprint = ""
|
||||
|
||||
var body: some View {
|
||||
Section("Known Hosts") {
|
||||
if entries.contains(where: \.isMismatched) {
|
||||
Label(
|
||||
"This server recently presented a key that is not in the trusted set.",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if entries.isEmpty {
|
||||
Text(
|
||||
"Add a trusted server key now, or let Termind save it on first connection."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach($entries) { $entry in
|
||||
knownHostRow($entry)
|
||||
}
|
||||
}
|
||||
|
||||
newHostKeyComposer
|
||||
|
||||
Text("Changes take effect only after you save this Host.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func knownHostRow(_ entry: Binding<KnownHostEntry>) -> some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
HStack {
|
||||
Label("Host Key", systemImage: "key.horizontal")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
let id = entry.wrappedValue.id
|
||||
entries.removeAll { $0.id == id }
|
||||
} label: {
|
||||
Label("Delete Host Key", systemImage: "trash")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
.help("Delete this Host Key")
|
||||
}
|
||||
|
||||
LabeledContent("Key Type") {
|
||||
algorithmPicker(selection: entry.algorithm)
|
||||
}
|
||||
|
||||
LabeledContent("Fingerprint") {
|
||||
TextField(
|
||||
"Fingerprint", text: entry.fingerprint,
|
||||
prompt: Text("SHA256:…")
|
||||
)
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.plain)
|
||||
.font(.body.monospaced())
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
if entry.wrappedValue.isMismatched {
|
||||
Label("Previously marked as mismatched", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.padding(Metrics.Space.m)
|
||||
.background(Color.primary.opacity(0.045), in: .rect(cornerRadius: Metrics.Radius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Metrics.Radius.medium)
|
||||
.strokeBorder(.separator.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
private var newHostKeyComposer: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
Label("Add Host Key", systemImage: "plus.circle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LabeledContent("Key Type") {
|
||||
algorithmPicker(selection: $newAlgorithm)
|
||||
}
|
||||
|
||||
LabeledContent("Fingerprint") {
|
||||
TextField(
|
||||
"Fingerprint", text: $newFingerprint,
|
||||
prompt: Text("SHA256:…")
|
||||
)
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.plain)
|
||||
.font(.body.monospaced())
|
||||
.multilineTextAlignment(.trailing)
|
||||
.onSubmit(addHostKey)
|
||||
}
|
||||
|
||||
HStack {
|
||||
if isDuplicateDraft {
|
||||
Text("This host key is already in the list.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Spacer()
|
||||
Button("Add", systemImage: "plus", action: addHostKey)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddHostKey)
|
||||
}
|
||||
}
|
||||
.padding(Metrics.Space.m)
|
||||
.background(Color.primary.opacity(0.045), in: .rect(cornerRadius: Metrics.Radius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Metrics.Radius.medium)
|
||||
.strokeBorder(.separator.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
private func algorithmPicker(selection: Binding<SSHKeyAlgorithm>) -> some View {
|
||||
Picker("Key Type", selection: selection) {
|
||||
ForEach(SSHKeyAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.displayName).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
private var normalizedNewFingerprint: String {
|
||||
newFingerprint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var draftIdentity: SSHHostKeyIdentity {
|
||||
SSHHostKeyIdentity(algorithm: newAlgorithm, fingerprint: normalizedNewFingerprint)
|
||||
}
|
||||
|
||||
private var isDuplicateDraft: Bool {
|
||||
!normalizedNewFingerprint.isEmpty && entries.contains { $0.identity == draftIdentity }
|
||||
}
|
||||
|
||||
private var canAddHostKey: Bool {
|
||||
!normalizedNewFingerprint.isEmpty && !isDuplicateDraft
|
||||
}
|
||||
|
||||
private func addHostKey() {
|
||||
guard canAddHostKey else { return }
|
||||
entries.append(
|
||||
KnownHostEntry(
|
||||
hostname: "", algorithm: newAlgorithm,
|
||||
fingerprint: normalizedNewFingerprint, addedAt: Date()
|
||||
)
|
||||
)
|
||||
newFingerprint = ""
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,10 @@ struct HostsPageView: ManagedCardPage {
|
||||
},
|
||||
.separator,
|
||||
tagFilterMenu,
|
||||
.separator,
|
||||
NativeMenuEntry(title: String(localized: "Import .ssh Folder"), symbol: MenuSymbol.importItem) {
|
||||
openWork(.sshImport(knownHostsOnly: false))
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,10 @@ struct KeychainPageView: ManagedCardPage {
|
||||
) {
|
||||
window.openVaultWork(.identityEditor(nil), on: .keychain)
|
||||
},
|
||||
.separator,
|
||||
NativeMenuEntry(title: String(localized: "Import .ssh Folder"), symbol: MenuSymbol.importItem) {
|
||||
window.openVaultWork(.sshImport(knownHostsOnly: false), on: .keychain)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class KnownHostsPageState {
|
||||
var query = ""
|
||||
var deleting: Set<UUID> = []
|
||||
var error: String?
|
||||
}
|
||||
|
||||
struct KnownHostsPageView: ManagedCardPage {
|
||||
typealias SectionID = String
|
||||
typealias Item = KnownHostEntry
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
let state: KnownHostsPageState
|
||||
|
||||
var managedSections: [ManagedCardSection<String, KnownHostEntry>] {
|
||||
[
|
||||
ManagedCardSection(
|
||||
id: "knownHosts", title: String(localized: "Known Hosts"),
|
||||
items: app.knownHosts.search(state.query), canAcceptDrop: false, showsWhenEmpty: false)
|
||||
]
|
||||
}
|
||||
var managedSelection: ManagedCardSelection<UUID> {
|
||||
ManagedCardSelection(
|
||||
selected: { Set(window.vaultSelection(for: .knownHosts).compactMap(\.knownHostID)) },
|
||||
update: { ids in
|
||||
window.setVaultSelection(
|
||||
app.knownHosts.entries.filter { ids.contains($0.id) }.map { .knownHost($0.id) }, for: .knownHosts)
|
||||
}
|
||||
)
|
||||
}
|
||||
var managedCapabilities: ManagedCardCapabilities<String, UUID> { ManagedCardCapabilities() }
|
||||
var isManagedSourceEmpty: Bool { app.knownHosts.entries.isEmpty }
|
||||
func managedHeader(for section: ManagedCardSection<String, KnownHostEntry>, isDropTarget: Bool) -> some View {
|
||||
CardSectionHeader(title: section.title, count: section.items.count)
|
||||
}
|
||||
func managedCard(for item: KnownHostEntry, context: ManagedCardItemContext<KnownHostEntry>) -> some View {
|
||||
KnownHostCard(
|
||||
entry: item, selected: context.isSelected, select: context.select, extend: context.extendSelection
|
||||
)
|
||||
.contextMenu {
|
||||
if context.actionItems.count == 1 {
|
||||
Button("Edit") { window.openVaultWork(.knownHostEditor(item.id), on: .knownHosts) }
|
||||
Button("Copy Fingerprint") { copyToPasteboard(item.fingerprint) }
|
||||
Divider()
|
||||
}
|
||||
Button("Delete", role: .destructive) { state.deleting = Set(context.actionItems.map(\.id)) }
|
||||
}
|
||||
.onTapGesture(count: 2) { window.openVaultWork(.knownHostEditor(item.id), on: .knownHosts) }
|
||||
}
|
||||
func managedGhost(for item: KnownHostEntry) -> some View {
|
||||
KnownHostCard(entry: item, selected: false, select: {}, extend: {})
|
||||
}
|
||||
func managedBlankAreaMenu(for section: String?) -> [NativeMenuEntry] {
|
||||
window.creationMenu(for: .knownHosts, locale: .current)
|
||||
}
|
||||
func managedEmptyState() -> some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Known Hosts", systemImage: "touchid")
|
||||
} description: {
|
||||
Text("Import trusted server keys or add a fingerprint manually.")
|
||||
} actions: {
|
||||
Button("Import Known Hosts") { window.openVaultWork(.sshImport(knownHostsOnly: true), on: .knownHosts) }
|
||||
Button("Add Host Key") { window.openVaultWork(.knownHostEditor(nil), on: .knownHosts) }
|
||||
}
|
||||
}
|
||||
func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) }
|
||||
var managedPresentation: KnownHostsPresentation {
|
||||
KnownHostsPresentation(
|
||||
state: state, ids: app.knownHosts.entries.map(\.id),
|
||||
prune: { window.pruneVaultSelections(in: app.hostLibrary) },
|
||||
delete: {
|
||||
if !app.knownHosts.delete(state.deleting) { state.error = app.knownHosts.storageError }
|
||||
state.deleting = []
|
||||
window.pruneVaultSelections(in: app.hostLibrary)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private struct KnownHostCard: View {
|
||||
let entry: KnownHostEntry
|
||||
let selected: Bool
|
||||
let select: () -> Void
|
||||
let extend: () -> Void
|
||||
var body: some View {
|
||||
CardTile(
|
||||
selectionID: VaultSelectionID.knownHost(entry.id), isSelected: selected, select: select,
|
||||
extendSelection: extend
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
HStack {
|
||||
CardIcon(symbol: "touchid", tint: entry.isMismatched ? .orange : TermindColor.accent)
|
||||
VStack(alignment: .leading) {
|
||||
Text(entry.isHashed ? String(localized: "Hashed Address") : entry.displayAddress)
|
||||
.font(.headline).lineLimit(1)
|
||||
Text(entry.algorithm.displayName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
HStack {
|
||||
if entry.isMismatched {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||||
.help("Previously marked as mismatched")
|
||||
}
|
||||
Text(entry.addedAt, style: .date).font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.help(entry.displayAddress)
|
||||
}
|
||||
}
|
||||
|
||||
struct KnownHostsPresentation: ManagedCardPresentationModifier {
|
||||
@Bindable var state: KnownHostsPageState
|
||||
let ids: [UUID]
|
||||
let prune: () -> Void
|
||||
let delete: () -> Void
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear(perform: prune)
|
||||
.onChange(of: ids) { prune() }
|
||||
.confirmationDialog(
|
||||
"Delete Host Keys?",
|
||||
isPresented: Binding(get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } })
|
||||
) {
|
||||
Button("Delete", role: .destructive, action: delete)
|
||||
} message: {
|
||||
Text("The next connection will ask you to verify the server key again.")
|
||||
}
|
||||
.alert(
|
||||
"Known Hosts Operation Failed",
|
||||
isPresented: Binding(get: { state.error != nil }, set: { if !$0 { state.error = nil } })
|
||||
) {
|
||||
Button("OK") { state.error = nil }
|
||||
} message: {
|
||||
Text(state.error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KnownHostEditorView: View {
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
let entryID: UUID?
|
||||
@State private var draft = KnownHostEntry(hostname: "")
|
||||
@State private var loaded = false
|
||||
@State private var error: String?
|
||||
var body: some View {
|
||||
VaultDetailScaffold(title: "Host Key", symbol: "touchid", close: window.closeVaultWork) {
|
||||
Form {
|
||||
TextField("Hostname", text: $draft.hostname)
|
||||
if !draft.isHashed { TextField("Port", value: $draft.port, format: .number.grouping(.never)) }
|
||||
Picker("Key Type", selection: $draft.algorithm) {
|
||||
ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
TextField("Fingerprint", text: $draft.fingerprint)
|
||||
.font(.body.monospaced())
|
||||
if draft.isHashed {
|
||||
Text("The original address and port cannot be recovered from this hash.").font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if draft.isMismatched {
|
||||
Label("Previously marked as mismatched", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let error { Text(error).foregroundStyle(.red) }
|
||||
}.formStyle(.grouped)
|
||||
} footer: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", action: window.closeVaultWork)
|
||||
Button("Save") {
|
||||
if app.knownHosts.save(draft) {
|
||||
window.selectVaultItem(.knownHost(draft.id), on: .knownHosts)
|
||||
window.closeVaultWork()
|
||||
} else {
|
||||
error = app.knownHosts.storageError
|
||||
}
|
||||
}.disabled(!draft.isValid)
|
||||
}.padding()
|
||||
}
|
||||
.onAppear {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
if let entryID, let entry = app.knownHosts.entry(entryID) { draft = entry }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SSHImportView: View {
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
let knownHostsOnly: Bool
|
||||
@State private var model: SSHImportModel?
|
||||
var body: some View {
|
||||
Group {
|
||||
if let model {
|
||||
SSHImportContent(model: model, knownHostsOnly: knownHostsOnly, close: window.closeVaultWork)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task { if model == nil { model = app.makeSSHImportModel() } }
|
||||
.onDisappear { if let model { Task { await model.discard() } } }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SSHImportContent: View {
|
||||
@Bindable var model: SSHImportModel
|
||||
let knownHostsOnly: Bool
|
||||
let close: () -> Void
|
||||
@State private var passphrases: [UUID: String] = [:]
|
||||
@State private var editingNames: Set<UUID> = []
|
||||
@State private var nativeDropKind: NativeDropKind?
|
||||
@State private var inputError: String?
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VaultDetailScaffold(
|
||||
title: knownHostsOnly ? "Import Known Hosts" : "Import .ssh Folder", symbol: "square.and.arrow.down",
|
||||
close: closeWhenIdle
|
||||
) {
|
||||
if model.completed {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
Label("Import Complete", systemImage: "checkmark.circle").font(.headline)
|
||||
Text("Imported \(model.importedCount) items.").foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.l) {
|
||||
sourcePicker
|
||||
if !knownHostsOnly {
|
||||
Picker("Store Keys in", selection: $model.storage) {
|
||||
Text("iCloud").tag(KeyStorage.iCloud)
|
||||
Text("This Mac").tag(KeyStorage.thisDevice)
|
||||
}
|
||||
}
|
||||
if !model.items.isEmpty {
|
||||
selectionButton(category: nil, title: String(localized: "Select All"))
|
||||
ForEach(SSHImportCategory.allCases) { category in
|
||||
if model.items.contains(where: { $0.category == category }) {
|
||||
categorySection(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let issue = model.routeProblem {
|
||||
Text(issue).font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
if let error = inputError ?? model.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.disabled(model.isWorking)
|
||||
}
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
||||
HStack {
|
||||
if !model.items.isEmpty && !model.completed {
|
||||
Text("\(model.selectedItems().count) selected")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if model.isWorking { ProgressView().controlSize(.small) }
|
||||
Spacer()
|
||||
Button(model.completed ? "Done" : "Cancel", action: close).disabled(model.isWorking)
|
||||
if !model.completed {
|
||||
Button("Import") {
|
||||
Task {
|
||||
await model.commit()
|
||||
passphrases = [:]
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent).disabled(!model.canImport)
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
.overlay {
|
||||
if nativeDropKind != nil {
|
||||
DropTargetOverlay(
|
||||
presentation: DropTargetPresentation(
|
||||
title: String(localized: "Drag to Import"),
|
||||
detail: knownHostsOnly
|
||||
? String(localized: "Known Hosts File") : String(localized: ".ssh Folder"),
|
||||
systemImage: knownHostsOnly ? "touchid" : "folder"
|
||||
))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
NativeDropTarget(
|
||||
targetedKind: $nativeDropKind, acceptedKinds: [.files],
|
||||
isEnabled: !model.isWorking && !model.completed,
|
||||
receive: { receiveDrop($0.fileURLs) })
|
||||
}
|
||||
.animation(Motion.dropTarget(reduceMotion: reduceMotion), value: nativeDropKind)
|
||||
}
|
||||
|
||||
private var sourcePicker: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
Label(
|
||||
knownHostsOnly ? "Known Hosts File" : ".ssh Folder", systemImage: knownHostsOnly ? "touchid" : "folder"
|
||||
)
|
||||
.font(.headline)
|
||||
if let source = model.sourcePath {
|
||||
Text(verbatim: source).font(.caption).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled)
|
||||
} else {
|
||||
Text(
|
||||
knownHostsOnly
|
||||
? "Drop a known_hosts file here, or choose a file."
|
||||
: "Drop a .ssh folder here, or choose a folder."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Button(knownHostsOnly ? "Choose Known Hosts File" : "Choose .ssh Folder") {
|
||||
Task {
|
||||
if let url = await SSHImportFilePicker.choose(folder: !knownHostsOnly) {
|
||||
inputError = nil
|
||||
passphrases = [:]
|
||||
await model.scan(url, knownHostsOnly: knownHostsOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.primary.opacity(0.035), in: .rect(cornerRadius: Metrics.Radius.medium))
|
||||
}
|
||||
|
||||
private func receiveDrop(_ urls: [URL]) -> Bool {
|
||||
guard urls.count == 1, let url = urls.first else {
|
||||
inputError = (knownHostsOnly ? SSHImportSourceError.expectedFile : .expectedFolder).localizedDescription
|
||||
return false
|
||||
}
|
||||
inputError = nil
|
||||
passphrases = [:]
|
||||
Task { await model.scan(url, knownHostsOnly: knownHostsOnly) }
|
||||
return true
|
||||
}
|
||||
|
||||
private func closeWhenIdle() { if !model.isWorking { close() } }
|
||||
|
||||
private func selectionButton(category: SSHImportCategory?, title: String) -> some View {
|
||||
let total = model.availableItems(category).count
|
||||
let selected = model.selectedItems(category).count
|
||||
let symbol = selected == 0 ? "square" : selected == total ? "checkmark.square.fill" : "minus.square.fill"
|
||||
return Button {
|
||||
model.setSelected(selected != total, category: category)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: symbol)
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text("\(selected) / \(total)").monospacedDigit().foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityValue(
|
||||
selected == 0
|
||||
? String(localized: "None selected")
|
||||
: selected == total ? String(localized: "All selected") : String(localized: "Partially selected")
|
||||
)
|
||||
.disabled(total == 0)
|
||||
}
|
||||
|
||||
private func categorySection(_ category: SSHImportCategory) -> some View {
|
||||
VStack(spacing: Metrics.Space.m) {
|
||||
HStack {
|
||||
Button {
|
||||
if model.expanded.contains(category) {
|
||||
model.expanded.remove(category)
|
||||
} else {
|
||||
model.expanded.insert(category)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: model.expanded.contains(category) ? "chevron.down" : "chevron.right")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(category.title)
|
||||
selectionButton(category: category, title: category.title)
|
||||
}.font(.headline)
|
||||
if model.expanded.contains(category) {
|
||||
ForEach($model.items) { $item in
|
||||
if item.category == category { itemRow($item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.primary.opacity(0.035), in: .rect(cornerRadius: Metrics.Radius.medium))
|
||||
}
|
||||
|
||||
private func sourceLabel(_ source: String) -> String {
|
||||
guard let root = model.sourcePath else { return source }
|
||||
if source.hasPrefix(root + "/") { return String(source.dropFirst(root.count + 1)) }
|
||||
if source.hasPrefix(root + ":") {
|
||||
return URL(fileURLWithPath: root).lastPathComponent + source.dropFirst(root.count)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
private func itemRow(_ binding: Binding<SSHImportItem>) -> some 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)
|
||||
VStack(alignment: .leading) {
|
||||
Text(item.name).fontWeight(.medium).lineLimit(2)
|
||||
Text(sourceLabel(item.source)).font(.caption).foregroundStyle(.secondary).lineLimit(2).help(
|
||||
item.source)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
if model.selectable(item) { binding.selected.wrappedValue.toggle() }
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.disabled(!model.selectable(item))
|
||||
if item.existingID != nil {
|
||||
Label("Already imported", systemImage: "checkmark.circle").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if item.host != nil && model.selectable(item) {
|
||||
if model.hasNameConflict(item) || editingNames.contains(item.id) {
|
||||
TextField(
|
||||
"Name",
|
||||
text: Binding(
|
||||
get: { binding.name.wrappedValue },
|
||||
set: {
|
||||
editingNames.insert(item.id)
|
||||
binding.name.wrappedValue = $0
|
||||
}
|
||||
))
|
||||
}
|
||||
let options = model.keyOptions(for: item)
|
||||
if options.count > 1 {
|
||||
Picker(
|
||||
"Key",
|
||||
selection: Binding(
|
||||
get: { model.chosenKey(for: item)?.id }, set: { binding.preferredKeyID.wrappedValue = $0 })
|
||||
) {
|
||||
ForEach(options) { key in Text(key.label).tag(Optional(key.id)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.requiresPassphrase {
|
||||
SecureField(
|
||||
"Passphrase", text: Binding(get: { passphrases[item.id] ?? "" }, set: { passphrases[item.id] = $0 })
|
||||
)
|
||||
Button("Unlock Key") {
|
||||
let passphrase = passphrases.removeValue(forKey: item.id) ?? ""
|
||||
Task { await model.unlock(item.id, passphrase: passphrase) }
|
||||
}
|
||||
}
|
||||
if let path = item.needsAuthorization {
|
||||
Button("Authorize File") {
|
||||
Task {
|
||||
if let url = await SSHImportFilePicker.choose(folder: path.contains("*"), suggestedPath: path) {
|
||||
await model.authorize(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let warnings = model.warnings(for: item)
|
||||
if !warnings.isEmpty {
|
||||
ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in
|
||||
Text(warning).font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
Toggle("Accept these import warnings", isOn: binding.acceptedWarnings).toggleStyle(.checkbox)
|
||||
}
|
||||
if let issue = model.problem(for: item, includeWarningReminder: false), item.existingID == nil {
|
||||
Text(issue).font(.caption).foregroundStyle(item.unavailable ? Color.secondary : Color.orange)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ struct SyncSettings: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
SyncSelectionRow(
|
||||
title: "Vault", detail: "Hosts, identities, snippets, and port forwards.",
|
||||
title: "Vault", detail: "Hosts, identities, snippets, known hosts, and port forwards.",
|
||||
isSelected: Binding(
|
||||
get: { includesVault },
|
||||
set: { value in
|
||||
|
||||
@@ -7,6 +7,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
|
||||
case hosts
|
||||
case keychain
|
||||
case snippets
|
||||
case knownHosts
|
||||
|
||||
nonisolated var id: String { rawValue }
|
||||
|
||||
@@ -16,6 +17,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
|
||||
case .hosts: "Hosts"
|
||||
case .keychain: "Keychain"
|
||||
case .snippets: "Snippets"
|
||||
case .knownHosts: "Known Hosts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +27,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
|
||||
case .hosts: AppLocalization.string("Hosts", locale: locale)
|
||||
case .keychain: AppLocalization.string("Keychain", locale: locale)
|
||||
case .snippets: AppLocalization.string("Snippets", locale: locale)
|
||||
case .knownHosts: AppLocalization.string("Known Hosts", locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +37,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
|
||||
case .hosts: "server.rack"
|
||||
case .keychain: "key.fill"
|
||||
case .snippets: "curlybraces"
|
||||
case .knownHosts: "touchid"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +47,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
|
||||
case hosts
|
||||
case keychain
|
||||
case snippets
|
||||
case knownHosts
|
||||
|
||||
init(page: VaultPage) {
|
||||
switch page {
|
||||
@@ -50,6 +55,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
|
||||
case .hosts: self = .hosts
|
||||
case .keychain: self = .keychain
|
||||
case .snippets: self = .snippets
|
||||
case .knownHosts: self = .knownHosts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +65,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
|
||||
case .hosts: .hosts
|
||||
case .keychain: .keychain
|
||||
case .snippets: .snippets
|
||||
case .knownHosts: .knownHosts
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +79,8 @@ nonisolated enum VaultWorkRoute: Hashable, Sendable {
|
||||
case identityEditor(Identity.ID?)
|
||||
case generateKey
|
||||
case importKey
|
||||
case knownHostEditor(KnownHostEntry.ID?)
|
||||
case sshImport(knownHostsOnly: Bool)
|
||||
case editKey(SSHKeyRef.ID)
|
||||
case exportKeys([SSHKeyRef.ID])
|
||||
case snippetEditor(snippetID: Snippet.ID?, packageID: SnippetPackage.ID?)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// Stable identity for the ordered selections owned by the three Vault manager pages.
|
||||
/// Stable identity for the ordered selections owned by the Vault manager pages.
|
||||
nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable {
|
||||
nonisolated enum Kind {
|
||||
static let host = "host"
|
||||
static let key = "key"
|
||||
static let identity = "identity"
|
||||
static let snippet = "snippet"
|
||||
static let knownHost = "knownHost"
|
||||
}
|
||||
|
||||
let kind: String
|
||||
@@ -32,6 +33,11 @@ nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable {
|
||||
Self(kind: Kind.snippet, rawID: id, page: .snippets)
|
||||
}
|
||||
|
||||
nonisolated static func knownHost(_ id: KnownHostEntry.ID) -> Self {
|
||||
Self(kind: Kind.knownHost, rawID: id, page: .knownHosts)
|
||||
}
|
||||
|
||||
var knownHostID: KnownHostEntry.ID? { kind == Kind.knownHost ? rawID : nil }
|
||||
var hostID: Host.ID? { kind == Kind.host ? rawID : nil }
|
||||
var keyID: SSHKeyRef.ID? { kind == Kind.key ? rawID : nil }
|
||||
var identityID: Identity.ID? { kind == Kind.identity ? rawID : nil }
|
||||
@@ -65,15 +71,15 @@ final class VaultSelectionState {
|
||||
|
||||
func clear(_ page: VaultPage) { selections[page] = [] }
|
||||
|
||||
func prune(available: Set<VaultSelectionID>) {
|
||||
for page in VaultPage.allCases {
|
||||
func prune(available: Set<VaultSelectionID>, pages: Set<VaultPage> = Set(VaultPage.allCases)) {
|
||||
for page in pages {
|
||||
let valid = selection(for: page).filter(available.contains)
|
||||
if valid != selection(for: page) { selections[page] = valid }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The three manager pages' query and confirmation state, held for the life of
|
||||
/// The manager pages' query and confirmation state, held for the life of
|
||||
/// the window rather than of the view.
|
||||
///
|
||||
/// Vault pages are torn down and rebuilt every time the destination changes, so
|
||||
@@ -86,6 +92,7 @@ final class VaultPageStates {
|
||||
let hosts = HostsPageState()
|
||||
let keychain = KeychainPageState()
|
||||
let snippets = SnippetsPageState()
|
||||
let knownHosts = KnownHostsPageState()
|
||||
let selection = VaultSelectionState()
|
||||
|
||||
/// The query belonging to a page, or nil for Welcome, which searches hosts
|
||||
@@ -96,6 +103,7 @@ final class VaultPageStates {
|
||||
case .hosts: hosts.query
|
||||
case .keychain: keychain.query
|
||||
case .snippets: snippets.query
|
||||
case .knownHosts: knownHosts.query
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ struct VaultView: View {
|
||||
case .hosts: HostsPageView(state: pages.hosts)
|
||||
case .keychain: KeychainPageView(state: pages.keychain)
|
||||
case .snippets: SnippetsPageView(state: pages.snippets)
|
||||
case .knownHosts: KnownHostsPageView(state: pages.knownHosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +58,10 @@ struct VaultWorkDetailView: View {
|
||||
)
|
||||
case .generateKey:
|
||||
KeychainGenerateDetail()
|
||||
case .knownHostEditor(let id):
|
||||
KnownHostEditorView(entryID: id)
|
||||
case .sshImport(let knownHostsOnly):
|
||||
SSHImportView(knownHostsOnly: knownHostsOnly)
|
||||
case .importKey:
|
||||
KeychainImportDetail()
|
||||
case .editKey(let id):
|
||||
|
||||
@@ -143,12 +143,12 @@ final class WindowModel {
|
||||
func pruneVaultSelections(in store: CredentialLibraryModel) {
|
||||
let available = Set(store.keys.map { VaultSelectionID.key($0.id) })
|
||||
.union(store.identities.map { .identity($0.id) })
|
||||
vaultPages.selection.prune(available: available)
|
||||
vaultPages.selection.prune(available: available, pages: [.keychain])
|
||||
}
|
||||
|
||||
func pruneVaultSelections(in store: SnippetLibraryModel) {
|
||||
vaultPages.selection.prune(
|
||||
available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) })
|
||||
available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) }), pages: [.snippets]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ final class WindowModel {
|
||||
.union(store.keys.map { .key($0.id) })
|
||||
.union(store.identities.map { .identity($0.id) })
|
||||
.union(store.snippets.map { .snippet($0.id) })
|
||||
.union(store.knownHosts.map { .knownHost($0.id) })
|
||||
}
|
||||
|
||||
typealias SidebarMode = WindowSidebarState.Mode
|
||||
@@ -373,6 +374,7 @@ final class WindowModel {
|
||||
case .hosts: return .hosts
|
||||
case .keychain: return .keychain
|
||||
case .snippets: return .snippets
|
||||
case .knownHosts: return .knownHosts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ struct WorkspaceNavigatorView: View {
|
||||
case .hosts: app.hostLibrary.hosts.count
|
||||
case .keychain: app.hostLibrary.keys.count + app.hostLibrary.identities.count
|
||||
case .snippets: app.hostLibrary.snippets.count
|
||||
case .knownHosts: app.knownHosts.entries.count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
|
||||
/// One import session owns its secrets. Only metadata leaves this actor until commit.
|
||||
actor SSHImportReader: SSHImportReading {
|
||||
private struct Material {
|
||||
var text: String
|
||||
var certificate: String
|
||||
}
|
||||
private var materials: [UUID: Material] = [:]
|
||||
private var stableIDs: [String: UUID] = [:]
|
||||
|
||||
func discard() { materials.removeAll() }
|
||||
|
||||
func scan(root: URL, knownHostsOnly: Bool, grants: [URL] = []) throws -> [SSHImportItem] {
|
||||
let scopes = [root] + grants
|
||||
let acquired = scopes.filter { $0.startAccessingSecurityScopedResource() }
|
||||
defer { for url in acquired { url.stopAccessingSecurityScopedResource() } }
|
||||
materials.removeAll()
|
||||
let sourceType = try root.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
|
||||
guard knownHostsOnly ? sourceType.isRegularFile == true : sourceType.isDirectory == true else {
|
||||
throw knownHostsOnly ? SSHImportSourceError.expectedFile : SSHImportSourceError.expectedFolder
|
||||
}
|
||||
if knownHostsOnly {
|
||||
return stabilize(SSHImportParser.knownHosts(try read(root), source: root.path))
|
||||
}
|
||||
let root = root.standardizedFileURL
|
||||
let authorized = scopes.map { $0.resolvingSymlinksInPath().standardizedFileURL.path }
|
||||
func allowed(_ url: URL) -> Bool {
|
||||
let path = url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
return authorized.contains { path == $0 || path.hasPrefix($0 + "/") }
|
||||
}
|
||||
var files: [String: String] = [:]
|
||||
var inaccessible: [String] = []
|
||||
var fileCount = 0
|
||||
var totalBytes = 0
|
||||
let manager = FileManager.default
|
||||
for scope in scopes {
|
||||
var directory: ObjCBool = false
|
||||
guard manager.fileExists(atPath: scope.path, isDirectory: &directory) else { continue }
|
||||
let urls: [URL]
|
||||
if directory.boolValue {
|
||||
let enumerator = manager.enumerator(
|
||||
at: scope, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [])
|
||||
urls = enumerator?.allObjects.compactMap { $0 as? URL } ?? []
|
||||
} else {
|
||||
urls = [scope]
|
||||
}
|
||||
for url in urls {
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue }
|
||||
guard allowed(url) else {
|
||||
inaccessible.append(url.path)
|
||||
continue
|
||||
}
|
||||
fileCount += 1
|
||||
guard fileCount <= 4096 else { throw CocoaError(.fileReadTooLarge) }
|
||||
do {
|
||||
let value = try read(url)
|
||||
totalBytes += value.utf8.count
|
||||
guard totalBytes <= 64 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) }
|
||||
files[url.standardizedFileURL.path] = value
|
||||
} catch {
|
||||
inaccessible.append(url.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
var parser = SSHConfigParser(
|
||||
root: root, home: URL(fileURLWithPath: NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory()),
|
||||
username: NSUserName(), files: files)
|
||||
var items = parser.parse()
|
||||
let knownPath = root.appendingPathComponent("known_hosts").path
|
||||
if let known = files[knownPath] { items += SSHImportParser.knownHosts(known, source: knownPath) }
|
||||
let hosts = items.compactMap(\.host)
|
||||
let referenced = Set(hosts.flatMap(\.identityPaths))
|
||||
let privateFiles = files.keys.filter { isPrivateKey(files[$0] ?? "") }
|
||||
let paths = Set(privateFiles).union(referenced.filter { files[$0] != nil })
|
||||
for path in paths.sorted() {
|
||||
let content = files[path] ?? ""
|
||||
guard isPrivateKey(content) else {
|
||||
items.append(
|
||||
SSHImportItem(
|
||||
category: .keys, name: URL(fileURLWithPath: path).lastPathComponent,
|
||||
source: path, selected: false,
|
||||
issue: String(localized: "No supported private key in this file."), unavailable: true))
|
||||
continue
|
||||
}
|
||||
let explicitCertificates = hosts.filter { $0.identityPaths.contains(path) }.flatMap(\.certificatePaths)
|
||||
let certificatePaths = Set(explicitCertificates + [path + "-cert.pub"])
|
||||
var variants: [String: [String]] = [:]
|
||||
for certificatePath in certificatePaths.sorted() {
|
||||
if let certificate = files[certificatePath] {
|
||||
variants[certificate, default: []].append(certificatePath)
|
||||
}
|
||||
}
|
||||
if files[path + "-cert.pub"] == nil { variants[""] = [] }
|
||||
for certificate in variants.keys.sorted() {
|
||||
let stable = path + "\n" + certificate
|
||||
let id = stableID(stable)
|
||||
materials[id] = Material(text: content, certificate: certificate)
|
||||
var item = SSHImportItem(
|
||||
id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path)
|
||||
item.certificatePaths = variants[certificate] ?? []
|
||||
do {
|
||||
item.key = try inspect(id: id, name: item.name, passphrase: nil)
|
||||
} catch {
|
||||
if isEncrypted(content) {
|
||||
item.requiresPassphrase = true
|
||||
item.issue = String(localized: "Enter the key passphrase to validate this private key.")
|
||||
} else {
|
||||
item.selected = false
|
||||
item.unavailable = true
|
||||
item.issue = String(localized: "No supported private key in this file.")
|
||||
}
|
||||
}
|
||||
items.append(item)
|
||||
}
|
||||
}
|
||||
let missing = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter { files[$0] == nil }
|
||||
let certificateMissing = hosts.flatMap(\.certificatePaths).filter { files[$0] == nil }
|
||||
for path in Set(inaccessible).union(parser.externalPaths).union(missing).union(certificateMissing).sorted() {
|
||||
items.append(
|
||||
SSHImportItem(
|
||||
category: path == knownPath ? .knownHosts : parser.externalPaths.contains(path) ? .hosts : .keys,
|
||||
name: URL(fileURLWithPath: path).lastPathComponent, source: path,
|
||||
issue: String(localized: "Authorize access to this referenced file, or deselect it."),
|
||||
needsAuthorization: path))
|
||||
}
|
||||
// Missing explicit references must also be visible on the affected host.
|
||||
for index in items.indices {
|
||||
guard let host = items[index].host else { continue }
|
||||
let absent = (host.identityPaths + host.certificatePaths).filter {
|
||||
files[$0] == nil && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0))
|
||||
}
|
||||
items[index].warnings += absent.map { String(localized: "Referenced file is unavailable:") + " " + $0 }
|
||||
}
|
||||
return stabilize(items)
|
||||
}
|
||||
|
||||
func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef {
|
||||
try inspect(id: id, name: name, passphrase: passphrase)
|
||||
}
|
||||
|
||||
func credentials(for ids: Set<UUID>) throws -> [UUID: StoredSSHKeyCredential] {
|
||||
var result: [UUID: StoredSSHKeyCredential] = [:]
|
||||
for id in ids {
|
||||
guard let material = materials[id] else { throw CocoaError(.fileReadUnknown) }
|
||||
result[id] = .software(SSHPrivateKeySecret(privateKey: material.text))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func inspect(id: UUID, name: String, passphrase: String?) throws -> SSHKeyRef {
|
||||
guard let input = materials[id] else { throw CocoaError(.fileReadUnknown) }
|
||||
let material = try SSHKeyMaterial.inspect(privateKey: input.text, passphrase: passphrase)
|
||||
if !input.certificate.isEmpty {
|
||||
_ = try SSHUserCertificate.parse(input.certificate, matching: material.publicKey)
|
||||
}
|
||||
return SSHKeyRef(
|
||||
id: id, label: name, algorithm: material.algorithm,
|
||||
publicKey: material.publicKey, certificate: input.certificate, fingerprint: material.fingerprint,
|
||||
credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize,
|
||||
hasPassphrase: passphrase?.isEmpty == false, createdAt: Date())
|
||||
}
|
||||
|
||||
private func stableID(_ source: String) -> UUID {
|
||||
if let id = stableIDs[source] { return id }
|
||||
let id = UUID()
|
||||
stableIDs[source] = id
|
||||
return id
|
||||
}
|
||||
|
||||
private func stabilize(_ values: [SSHImportItem]) -> [SSHImportItem] {
|
||||
var occurrences: [String: Int] = [:]
|
||||
return values.map { item in
|
||||
var item = item
|
||||
if materials[item.id] == nil {
|
||||
let token = "\(item.category.rawValue):\(item.source):\(item.name)"
|
||||
let occurrence = occurrences[token, default: 0]
|
||||
occurrences[token] = occurrence + 1
|
||||
item.id = stableID(token + ":\(occurrence)")
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private func read(_ url: URL) throws -> String {
|
||||
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
|
||||
guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else {
|
||||
throw CocoaError(.fileReadTooLarge)
|
||||
}
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func isEncrypted(_ text: String) -> Bool {
|
||||
if text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") || text.contains("DEK-Info:") { return true }
|
||||
guard text.hasPrefix("-----BEGIN OPENSSH PRIVATE KEY-----") else { return false }
|
||||
let encoded = text.components(separatedBy: .newlines).filter { !$0.hasPrefix("-----") }.joined()
|
||||
let magic = Data("openssh-key-v1\0".utf8)
|
||||
guard let data = Data(base64Encoded: encoded), data.starts(with: magic), data.count >= magic.count + 4 else {
|
||||
return false
|
||||
}
|
||||
let offset = magic.count
|
||||
let length = data[offset..<offset + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
guard Int(length) <= data.count - offset - 4 else { return false }
|
||||
let cipher = String(data: data.subdata(in: offset + 4..<offset + 4 + Int(length)), encoding: .utf8)
|
||||
return cipher != nil && cipher != "none"
|
||||
}
|
||||
|
||||
private func isPrivateKey(_ text: String) -> Bool {
|
||||
[
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----",
|
||||
].contains { text.hasPrefix($0) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum SSHImportFilePicker {
|
||||
static func choose(folder: Bool, suggestedPath: String? = nil) async -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = folder
|
||||
panel.canChooseFiles = !folder
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.showsHiddenFiles = true
|
||||
if let suggestedPath {
|
||||
let url = URL(fileURLWithPath: suggestedPath)
|
||||
panel.directoryURL = folder ? url : url.deletingLastPathComponent()
|
||||
} else {
|
||||
panel.directoryURL = URL(fileURLWithPath: NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory())
|
||||
}
|
||||
let response = await panel.begin()
|
||||
return response == .OK ? panel.url : nil
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,7 @@ nonisolated enum WindowToolbarFindKind: String, Hashable, Sendable, CaseIterable
|
||||
case processes
|
||||
case keychain
|
||||
case snippets
|
||||
case knownHosts
|
||||
case assistant
|
||||
case terminal
|
||||
case sftp
|
||||
@@ -549,6 +550,7 @@ struct WindowToolbarLeadingActions: View {
|
||||
} else if !actions.isEmpty {
|
||||
Menu {
|
||||
ForEach(actions) { action in
|
||||
if action.startsGroup { Divider() }
|
||||
Button(
|
||||
action.localizedTitle(locale: locale),
|
||||
systemImage: action.symbol,
|
||||
@@ -574,15 +576,17 @@ struct WindowToolbarCreationAction: Identifiable {
|
||||
/// menu item and as a `NativeMenuEntry`, and a new verb that forgot its
|
||||
/// glyph would go unnoticed in one of the two.
|
||||
let symbol: String
|
||||
let startsGroup: Bool
|
||||
let perform: () -> Void
|
||||
|
||||
init(
|
||||
id: String, title: String.LocalizationValue, symbol: String,
|
||||
id: String, title: String.LocalizationValue, symbol: String, startsGroup: Bool = false,
|
||||
perform: @escaping () -> Void
|
||||
) {
|
||||
self.id = id
|
||||
titleKey = title
|
||||
self.symbol = symbol
|
||||
self.startsGroup = startsGroup
|
||||
self.perform = perform
|
||||
}
|
||||
|
||||
@@ -627,6 +631,9 @@ extension WindowModel {
|
||||
/// right-click on that page's row in the Navigator offers, so a host can be
|
||||
/// added without first visiting the page to add it on.
|
||||
func creationActions(for page: VaultPage) -> [WindowToolbarCreationAction] {
|
||||
let importFolder = WindowToolbarCreationAction(
|
||||
id: "ssh-folder", title: "Import .ssh Folder", symbol: MenuSymbol.importItem, startsGroup: true
|
||||
) { self.openVaultWork(.sshImport(knownHostsOnly: false), on: page) }
|
||||
switch page {
|
||||
case .welcome:
|
||||
return []
|
||||
@@ -644,6 +651,7 @@ extension WindowModel {
|
||||
.hostGroupEditor(groupID: nil, parentID: nil), on: .hosts
|
||||
)
|
||||
},
|
||||
importFolder,
|
||||
]
|
||||
case .keychain:
|
||||
return [
|
||||
@@ -662,6 +670,19 @@ extension WindowModel {
|
||||
) {
|
||||
self.openVaultWork(.identityEditor(nil), on: .keychain)
|
||||
},
|
||||
importFolder,
|
||||
]
|
||||
case .knownHosts:
|
||||
return [
|
||||
WindowToolbarCreationAction(id: "known-host", title: "Add Host Key", symbol: "plus") {
|
||||
self.openVaultWork(.knownHostEditor(nil), on: .knownHosts)
|
||||
},
|
||||
WindowToolbarCreationAction(
|
||||
id: "known-hosts-import", title: "Import Known Hosts", symbol: MenuSymbol.importItem
|
||||
) {
|
||||
self.openVaultWork(.sshImport(knownHostsOnly: true), on: .knownHosts)
|
||||
},
|
||||
importFolder,
|
||||
]
|
||||
case .snippets:
|
||||
return [
|
||||
@@ -685,12 +706,11 @@ extension WindowModel {
|
||||
|
||||
/// Those verbs as native menu entries, for the Navigator's own rows.
|
||||
func creationMenu(for page: VaultPage, locale: Locale) -> [NativeMenuEntry] {
|
||||
creationActions(for: page).map { action in
|
||||
NativeMenuEntry(
|
||||
title: action.localizedTitle(locale: locale),
|
||||
symbol: action.symbol,
|
||||
action: action.perform
|
||||
creationActions(for: page).flatMap { action in
|
||||
let entry = NativeMenuEntry(
|
||||
title: action.localizedTitle(locale: locale), symbol: action.symbol, action: action.perform
|
||||
)
|
||||
return action.startsGroup ? [.separator, entry] : [entry]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +743,12 @@ extension WindowModel {
|
||||
query: Binding(get: { state.query }, set: { state.query = $0 }),
|
||||
focusRequest: findFocusRequest
|
||||
)
|
||||
case .knownHosts:
|
||||
let state = vaultPages.knownHosts
|
||||
return WindowToolbarFindContext(
|
||||
query: Binding(get: { state.query }, set: { state.query = $0 }),
|
||||
focusRequest: findFocusRequest
|
||||
)
|
||||
case .snippets:
|
||||
let state = vaultPages.snippets
|
||||
return WindowToolbarFindContext(
|
||||
|
||||
@@ -347,41 +347,16 @@ struct ConnectionJourneyTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostEditorCommitNormalizesAndReplacesKnownHostsWithTheHost() {
|
||||
func hostEditorCommitDoesNotMoveSharedKnownHosts() {
|
||||
var host = Host(name: "Server", host: "old.example")
|
||||
let oldKey = KnownHostEntry(
|
||||
hostname: host.host, algorithm: .ed25519, fingerprint: "SHA256:old"
|
||||
)
|
||||
let unrelated = KnownHostEntry(
|
||||
hostname: "other.example", algorithm: .ed25519, fingerprint: "SHA256:other"
|
||||
)
|
||||
let store = HostLibraryModel(hosts: [host], knownHosts: [oldKey, unrelated])
|
||||
let shared = Host(name: "Other Login", host: "old.example")
|
||||
let entry = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:old")
|
||||
let store = HostLibraryModel(hosts: [host, shared], knownHosts: [entry])
|
||||
host.host = "new.example"
|
||||
host.port = 2222
|
||||
let manual = KnownHostEntry(
|
||||
hostname: "", algorithm: .ed25519, fingerprint: " SHA256:manual "
|
||||
)
|
||||
|
||||
#expect(
|
||||
store.upsert(
|
||||
host, replacingPortForwardings: [], replacingKnownHosts: [manual]
|
||||
)
|
||||
)
|
||||
#expect(store.knownHosts.contains(unrelated))
|
||||
#expect(!store.knownHosts.contains { $0.hostname == "old.example" })
|
||||
let saved = store.knownHosts(for: host)
|
||||
#expect(saved.count == 1)
|
||||
#expect(saved[0].hostname == "new.example")
|
||||
#expect(saved[0].port == 2222)
|
||||
#expect(saved[0].algorithm == .ed25519)
|
||||
#expect(saved[0].fingerprint == "SHA256:manual")
|
||||
|
||||
#expect(
|
||||
!store.upsert(
|
||||
host, replacingPortForwardings: [],
|
||||
replacingKnownHosts: [manual, manual]
|
||||
)
|
||||
)
|
||||
#expect(store.knownHosts(for: host) == saved)
|
||||
#expect(store.upsert(host, replacingPortForwardings: []))
|
||||
#expect(store.knownHosts == [entry])
|
||||
#expect(store.knownHosts(for: host).isEmpty)
|
||||
#expect(store.knownHosts(for: shared) == [entry])
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,25 @@ struct RepositoryOutboxTests {
|
||||
#expect(try store.nextReady(at: Date()) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingHostPreservesItsIndependentKnownHostRecords() throws {
|
||||
let container = try makeContainer("IndependentKnownHosts")
|
||||
let context = container.mainContext
|
||||
let host = Host(name: "Removed", host: "shared.example")
|
||||
let remaining = Host(name: "Another Login", host: host.host)
|
||||
let trust = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:trusted")
|
||||
context.insert(PersistentHost(host))
|
||||
context.insert(PersistentHost(remaining))
|
||||
context.insert(PersistentKnownHost(trust))
|
||||
try context.save()
|
||||
let repository = SwiftDataHostRepository(context: context)
|
||||
try repository.requestHostDeletion(host.id)
|
||||
try repository.clearReferencesForDeletedHost(host.id)
|
||||
try repository.finalizeHostDeletion(host.id)
|
||||
#expect(try repository.snapshot().value.hosts.map(\.id) == [remaining.id])
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentKnownHost>()).map(\.value) == [trust])
|
||||
}
|
||||
|
||||
private func makeContainer(_ name: String) throws -> ModelContainer {
|
||||
let configuration = localModelConfiguration(
|
||||
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
struct SSHImportTests {
|
||||
private func root() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("SSHImport-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
private func hash(_ address: String) -> String {
|
||||
let salt = Data(repeating: 7, count: 20)
|
||||
let digest = HMAC<Insecure.SHA1>.authenticationCode(for: Data(address.utf8), using: SymmetricKey(data: salt))
|
||||
return "|1|\(salt.base64EncodedString())|\(Data(digest).base64EncodedString())"
|
||||
}
|
||||
|
||||
@Test
|
||||
func hashedKeysMatchPortsAndSurvivePersistenceWithoutHosts() throws {
|
||||
let entry = KnownHostEntry(hostname: hash("[2001:db8::1]:2222"), fingerprint: "SHA256:key")
|
||||
let decoded = try JSONDecoder().decode(KnownHostEntry.self, from: JSONEncoder().encode(entry))
|
||||
#expect(decoded.matches(hostname: "2001:db8::1", port: 2222))
|
||||
#expect(!decoded.matches(hostname: "2001:db8::1", port: 22))
|
||||
let store = HostLibraryModel(knownHosts: [decoded])
|
||||
let host = Host(name: "IPv6", host: "2001:db8::1", port: 2222)
|
||||
#expect(try store.expectedHostKeys(for: host) == [entry.identity])
|
||||
store.markKnownHostMismatched(hostname: host.host, port: host.port)
|
||||
#expect(store.knownHosts[0].isMismatched)
|
||||
store.forgetKnownHosts(for: host)
|
||||
#expect(store.knownHosts.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func configHonorsFirstValuesHostPatternsAndOrderedIncludes() {
|
||||
let root = URL(fileURLWithPath: "/fixture/.ssh")
|
||||
var parser = SSHConfigParser(
|
||||
root: root, home: URL(fileURLWithPath: "/fixture"), username: "local",
|
||||
files: [
|
||||
"/fixture/.ssh/config": """
|
||||
Host prod alias
|
||||
HostName prod.example
|
||||
User deploy
|
||||
IdentityFile "~/.ssh/deploy key"
|
||||
Include conf/*.conf
|
||||
Host * !alias
|
||||
Port 2222
|
||||
Host *
|
||||
User fallback
|
||||
""",
|
||||
"/fixture/.ssh/conf/01.conf": "Port=2200\nIdentityFile ~/.ssh/second",
|
||||
"/fixture/.ssh/conf/02.conf": "Port 2300",
|
||||
])
|
||||
let entries = parser.parse().compactMap(\.host)
|
||||
#expect(entries.count == 2)
|
||||
#expect(entries.allSatisfy { $0.port == 2200 && $0.username == "deploy" })
|
||||
#expect(entries[0].identityPaths == ["/fixture/.ssh/deploy key", "/fixture/.ssh/second"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func configReportsCyclesAndDoesNotExecuteMatchOrProxyCommands() {
|
||||
let root = URL(fileURLWithPath: "/fixture")
|
||||
var parser = SSHConfigParser(
|
||||
root: root, home: root, username: "local",
|
||||
files: [
|
||||
"/fixture/config":
|
||||
"Host app\nHostName app.example\nProxyCommand touch /tmp/never\nInclude loop\nMatch exec \"exit 0\"\nUser wrong",
|
||||
"/fixture/loop": "Include config",
|
||||
])
|
||||
let entries = parser.parse()
|
||||
let host = entries.first { $0.host != nil }
|
||||
#expect(host?.host?.username == "local")
|
||||
#expect(host?.warnings.contains(where: { $0.contains("proxycommand") }) == true)
|
||||
#expect(host?.warnings.contains(where: { $0.contains("Match") }) == true)
|
||||
#expect(entries.contains { $0.unavailable })
|
||||
}
|
||||
|
||||
@Test
|
||||
func knownHostsValidatesWireKeysAndPreservesMultipleEndpoints() throws {
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
||||
let content = """
|
||||
# comment
|
||||
example,[2001:db8::1]:2222 \(material.publicKey)
|
||||
\(hash("other.example")) \(material.publicKey)
|
||||
@revoked revoked.example \(material.publicKey)
|
||||
*.wild.example \(material.publicKey)
|
||||
broken ssh-ed25519 AAAA
|
||||
"""
|
||||
let rows = SSHImportParser.knownHosts(content, source: "known_hosts")
|
||||
#expect(rows.filter { !$0.unavailable }.count == 3)
|
||||
#expect(rows.compactMap(\.knownHost).allSatisfy { $0.fingerprint == material.fingerprint })
|
||||
#expect(rows.filter(\.unavailable).count == 3)
|
||||
#expect(rows.compactMap(\.knownHost).contains { $0.matches(hostname: "other.example", port: 22) })
|
||||
}
|
||||
|
||||
@Test
|
||||
func droppedSourcesUseTheSameTypeValidationAsTheFilePicker() async throws {
|
||||
let url = try root()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let file = url.appendingPathComponent("known_hosts")
|
||||
try "# empty".write(to: file, atomically: true, encoding: .utf8)
|
||||
let reader = SSHImportReader()
|
||||
await #expect(throws: SSHImportSourceError.expectedFolder) {
|
||||
_ = try await reader.scan(root: file, knownHostsOnly: false)
|
||||
}
|
||||
await #expect(throws: SSHImportSourceError.expectedFile) {
|
||||
_ = try await reader.scan(root: url, knownHostsOnly: true)
|
||||
}
|
||||
#expect(try await reader.scan(root: file, knownHostsOnly: true).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedPrivateKeysAreUnavailableRatherThanAskingForAPassphrase() async throws {
|
||||
let url = try root()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
try "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----".write(
|
||||
to: url.appendingPathComponent("broken"), atomically: true, encoding: .utf8)
|
||||
let reader = SSHImportReader()
|
||||
let rows = try await reader.scan(root: url, knownHostsOnly: false)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].unavailable && !rows[0].selected && !rows[0].requiresPassphrase)
|
||||
}
|
||||
|
||||
@Test
|
||||
func selectionStartsCheckedAndCollapseDoesNotChangeIt() {
|
||||
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
||||
model.items = [
|
||||
SSHImportItem(category: .hosts, name: "a", source: "config"),
|
||||
SSHImportItem(category: .keys, name: "b", source: "key"),
|
||||
SSHImportItem(category: .knownHosts, name: "c", source: "known_hosts"),
|
||||
SSHImportItem(category: .keys, name: "bad", source: "bad", selected: false, unavailable: true),
|
||||
]
|
||||
#expect(model.selectedItems().count == 3)
|
||||
model.expanded.remove(.keys)
|
||||
#expect(model.selectedItems().count == 3)
|
||||
model.setSelected(false, category: .keys)
|
||||
#expect(model.selectedItems().count == 2)
|
||||
model.setSelected(false)
|
||||
#expect(!model.canImport)
|
||||
model.setSelected(true)
|
||||
#expect(model.selectedItems().count == 3)
|
||||
#expect(!model.items[3].selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func folderImportMigratesSecretsAndIsIdempotent() 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("id_ed25519"), atomically: true, encoding: .utf8)
|
||||
try "Host prod\n HostName prod.example\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n".write(
|
||||
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
||||
try "prod.example \(material.publicKey)".write(
|
||||
to: url.appendingPathComponent("known_hosts"), atomically: true, encoding: .utf8)
|
||||
let store = HostLibraryModel()
|
||||
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
||||
await model.scan(url, knownHostsOnly: false)
|
||||
#expect(model.selectedItems().count == 3)
|
||||
#expect(store.hosts.isEmpty && store.keys.isEmpty)
|
||||
#expect(model.canImport)
|
||||
await model.commit()
|
||||
#expect(model.completed)
|
||||
#expect(store.hosts.count == 1 && store.keys.count == 1 && store.knownHosts.count == 1)
|
||||
let identity = try #require(store.identities.first)
|
||||
#expect(identity.username == "deploy" && identity.keyID == store.keys.first?.id)
|
||||
#expect(store.keyAvailability(for: try #require(store.keys.first)) == .available)
|
||||
let again = SSHImportModel(library: store, reader: SSHImportReader())
|
||||
await again.scan(url, knownHostsOnly: false)
|
||||
#expect(again.selectedItems().isEmpty)
|
||||
#expect(!again.canImport)
|
||||
}
|
||||
|
||||
@Test
|
||||
func encryptedKeyCanBeUnlockedAndDeselectionRefreshesHostAssociation() async throws {
|
||||
let url = try root()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "test-password")
|
||||
try material.privateKey.write(to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8)
|
||||
try "Host prod\nIdentityFile ~/.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 id = try #require(model.items.first { $0.category == .keys }?.id)
|
||||
#expect(!model.canImport)
|
||||
await model.unlock(id, passphrase: "wrong")
|
||||
#expect(!model.canImport)
|
||||
await model.unlock(id, passphrase: "test-password")
|
||||
#expect(model.canImport)
|
||||
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.canImport)
|
||||
}
|
||||
|
||||
@Test
|
||||
func jumpDependenciesAndCyclesBlockCommit() {
|
||||
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
||||
model.items = [
|
||||
SSHImportItem(
|
||||
category: .hosts, name: "app", source: "config",
|
||||
host: SSHImportedHost(alias: "app", hostname: "app", username: "u", jumps: ["jump"])),
|
||||
SSHImportItem(
|
||||
category: .hosts, name: "jump", source: "config",
|
||||
host: SSHImportedHost(alias: "jump", hostname: "jump", username: "u")),
|
||||
]
|
||||
#expect(model.canImport)
|
||||
model.items[1].selected = false
|
||||
#expect(!model.canImport)
|
||||
model.items[1].selected = true
|
||||
model.items[1].host?.jumps = ["app"]
|
||||
#expect(!model.canImport)
|
||||
#expect(model.routeProblem != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func importRetainsMismatchWarningsAndRequiresConflictReview() async {
|
||||
let original = KnownHostEntry(hostname: "host", fingerprint: "SHA256:old", isMismatched: true)
|
||||
let store = HostLibraryModel(knownHosts: [original])
|
||||
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
||||
model.items = [
|
||||
SSHImportItem(
|
||||
category: .knownHosts, name: "host", source: "known_hosts",
|
||||
knownHost: KnownHostEntry(hostname: "host", fingerprint: "SHA256:new"))
|
||||
]
|
||||
#expect(!model.canImport)
|
||||
model.items[0].acceptedWarnings = true
|
||||
#expect(model.canImport)
|
||||
await model.commit()
|
||||
#expect(model.completed)
|
||||
#expect(store.knownHosts.first?.isMismatched == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func multiHopImportsPreserveStandaloneJumpConfigurations() async throws {
|
||||
let url = try root()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
try "Host app\nProxyJump first,second\nHost first\nHost second".write(
|
||||
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
||||
let store = HostLibraryModel()
|
||||
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
||||
await model.scan(url, knownHostsOnly: false)
|
||||
#expect(model.canImport)
|
||||
await model.commit()
|
||||
#expect(model.completed)
|
||||
let second = try #require(store.hosts.first { $0.name == "second" })
|
||||
#expect(second.jumpHostID == nil)
|
||||
let app = try #require(store.hosts.first { $0.name == "app" })
|
||||
let intermediate = try #require(app.jumpHostID.flatMap(store.host))
|
||||
#expect(intermediate.host == "second")
|
||||
#expect(intermediate.jumpHostID.flatMap(store.host)?.name == "first")
|
||||
let again = SSHImportModel(library: store, reader: SSHImportReader())
|
||||
await again.scan(url, knownHostsOnly: false)
|
||||
#expect(again.selectedItems().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func externalKeyIsOnlyReadAfterExplicitAuthorization() async throws {
|
||||
let url = try root()
|
||||
let external = try root()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
try? FileManager.default.removeItem(at: external)
|
||||
}
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
||||
let keyURL = external.appendingPathComponent("deploy")
|
||||
try material.privateKey.write(to: keyURL, atomically: true, encoding: .utf8)
|
||||
try "Host app\nIdentityFile \(keyURL.path)".write(
|
||||
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
||||
let reader = SSHImportReader()
|
||||
let before = try await reader.scan(root: url, knownHostsOnly: false)
|
||||
#expect(before.compactMap(\.key).isEmpty)
|
||||
#expect(before.contains { $0.needsAuthorization == keyURL.path })
|
||||
let after = try await reader.scan(root: url, knownHostsOnly: false, grants: [keyURL])
|
||||
#expect(after.compactMap(\.key).count == 1)
|
||||
#expect(!after.contains { $0.needsAuthorization != nil })
|
||||
}
|
||||
|
||||
@Test
|
||||
func keychainPagePruningDoesNotEraseKnownHostSelection() {
|
||||
let entry = KnownHostEntry(hostname: "host", fingerprint: "SHA256:key")
|
||||
let store = HostLibraryModel(knownHosts: [entry])
|
||||
let window = WindowModel()
|
||||
window.selectVaultItem(.knownHost(entry.id), on: .knownHosts)
|
||||
window.pruneVaultSelections(in: CredentialLibraryModel(library: store))
|
||||
#expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)])
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedSnapshotRollsBackMetadataAndNewCredentials() throws {
|
||||
let persistence = FailingImportPersistence()
|
||||
let secrets = InMemorySecretStore()
|
||||
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
||||
let key = SSHKeyRef(label: "Imported", credentialRef: "import-test")
|
||||
let batch = SSHImportBatch(
|
||||
hosts: [Host(name: "new", host: "new")], keys: [key],
|
||||
knownHosts: [KnownHostEntry(hostname: "new", fingerprint: "SHA256:key")],
|
||||
credentials: [key.id: .software(SSHPrivateKeySecret(privateKey: "test-private-material"))])
|
||||
#expect(throws: (any Error).self) { try store.commitSSHImport(batch) }
|
||||
#expect(store.hosts.isEmpty && store.keys.isEmpty && store.knownHosts.isEmpty)
|
||||
#expect(try secrets.keyCredential(for: key.credentialRef, storage: key.storage) == nil)
|
||||
#expect(!store.saveKnownHost(KnownHostEntry(hostname: "new", fingerprint: "SHA256:key")))
|
||||
#expect(store.knownHosts.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func vaultKnownHostNavigationUsesFingerprintAndRetainsSelection() {
|
||||
let entry = KnownHostEntry(hostname: "host", fingerprint: "SHA256:key")
|
||||
let store = HostLibraryModel(knownHosts: [entry])
|
||||
let window = WindowModel()
|
||||
window.selectVaultItem(.knownHost(entry.id), on: .knownHosts)
|
||||
window.vaultPages.knownHosts.query = "key"
|
||||
window.openPage(.hosts)
|
||||
window.openPage(.knownHosts)
|
||||
window.pruneVaultSelections(in: store)
|
||||
#expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)])
|
||||
#expect(window.vaultPages.knownHosts.query == "key")
|
||||
#expect(window.findKind == .knownHosts)
|
||||
#expect(VaultPage.knownHosts.symbol == "touchid")
|
||||
#expect(VaultPage.allCases.last == .knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FailingImportPersistence: VaultPersisting {
|
||||
func load() throws -> VaultSnapshot { VaultSnapshot() }
|
||||
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { throw CocoaError(.fileWriteUnknown) }
|
||||
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,30 @@ struct SSHUserCertificateTests {
|
||||
#expect(parsed.validityWarning != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func folderImportAssociatesExplicitCertificatesWithTheirHost() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("SSHCertificateImport-\(UUID())")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
||||
let certificate = try makeEd25519Certificate(
|
||||
publicKey: material.publicKey, type: 1, validAfter: 0, validBefore: UInt64.max)
|
||||
try material.privateKey.write(to: root.appendingPathComponent("deploy"), atomically: true, encoding: .utf8)
|
||||
try certificate.write(to: root.appendingPathComponent("user-cert.pub"), atomically: true, encoding: .utf8)
|
||||
try
|
||||
"Host certified\nIdentityFile ~/.ssh/deploy\nCertificateFile ~/.ssh/user-cert.pub\nHost plain\nIdentityFile ~/.ssh/deploy"
|
||||
.write(
|
||||
to: root.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
||||
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
||||
await model.scan(root, knownHostsOnly: false)
|
||||
let certified = try #require(model.items.first { $0.name == "certified" })
|
||||
let plain = try #require(model.items.first { $0.name == "plain" })
|
||||
#expect(model.chosenKey(for: certified)?.certificate == certificate)
|
||||
#expect(model.chosenKey(for: plain)?.certificate == "")
|
||||
#expect(model.canImport)
|
||||
}
|
||||
|
||||
private func makeEd25519Certificate(
|
||||
publicKey: String, type: UInt32, validAfter: UInt64, validBefore: UInt64
|
||||
) throws -> String {
|
||||
|
||||
@@ -37,6 +37,55 @@ struct VaultSyncTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func importedKnownHostsSyncWithoutHostsAndPropagateEditsAndDeletion() throws {
|
||||
let source = try makeContainer("KnownHostsSource")
|
||||
let destination = try makeContainer("KnownHostsDestination")
|
||||
let sourcePersistence = HostPersistence(context: source.mainContext)
|
||||
let destinationPersistence = HostPersistence(context: destination.mainContext)
|
||||
let library = HostLibraryModel(persistence: sourcePersistence)
|
||||
let journal = VaultSyncJournal(context: source.mainContext)
|
||||
let hashedAddress =
|
||||
"|1|" + Data(repeating: 7, count: 20).base64EncodedString()
|
||||
+ "|" + Data(repeating: 9, count: 20).base64EncodedString()
|
||||
let plain = KnownHostEntry(hostname: "sync.example", port: 2222, fingerprint: "SHA256:plain")
|
||||
let hashed = KnownHostEntry(hostname: hashedAddress, fingerprint: "SHA256:hashed")
|
||||
var published = VaultMutationSet.empty
|
||||
library.didRecordVaultChanges = { published = $0 }
|
||||
|
||||
try library.commitSSHImport(SSHImportBatch(knownHosts: [plain, hashed]))
|
||||
#expect(published.keys == Set([plain, hashed].map { VaultRecordKey(kind: .knownHost, id: $0.id) }))
|
||||
#expect(try source.mainContext.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).allSatisfy(\.needsUpload))
|
||||
|
||||
func transfer(_ id: UUID) throws {
|
||||
let key = VaultRecordKey(kind: .knownHost, id: id)
|
||||
let envelope = try #require(try journal.envelope(for: key))
|
||||
let recordID = CKRecord.ID(
|
||||
recordName: key.recordName,
|
||||
zoneID: CKRecordZone.ID(
|
||||
zoneName: VaultSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName))
|
||||
let record = try VaultSyncCoordinator.makeRecord(VaultOutgoingEntry(envelope: envelope), recordID: recordID)
|
||||
let received = try VaultSyncCoordinator.envelope(from: record)
|
||||
_ = try destinationPersistence.applyRemote([received])
|
||||
}
|
||||
|
||||
try transfer(plain.id)
|
||||
try transfer(hashed.id)
|
||||
let received = try destinationPersistence.load()
|
||||
#expect(received.hosts.isEmpty)
|
||||
#expect(Set(received.knownHosts) == Set([plain, hashed]))
|
||||
|
||||
var edited = plain
|
||||
edited.isMismatched = true
|
||||
#expect(library.saveKnownHost(edited))
|
||||
try transfer(plain.id)
|
||||
#expect(try destinationPersistence.load().knownHosts.first { $0.id == plain.id }?.isMismatched == true)
|
||||
|
||||
#expect(library.deleteKnownHosts([plain.id]))
|
||||
try transfer(plain.id)
|
||||
#expect(try destinationPersistence.load().knownHosts == [hashed])
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyCredentialPayloadsWithoutColorsDecodeAsNone() throws {
|
||||
let writer = UUID()
|
||||
|
||||
@@ -129,20 +129,33 @@ struct WindowToolbarFindTests {
|
||||
let window = WindowModel()
|
||||
|
||||
window.openPage(.hosts)
|
||||
#expect(window.creationActions.map(\.id) == ["host", "group"])
|
||||
#expect(window.creationActions.map(\.id) == ["host", "group", "ssh-folder"])
|
||||
|
||||
// Switching the sidebar to the Assistant must not put New Chat in the
|
||||
// titlebar while Hosts is still the page being looked at.
|
||||
window.sidebarMode = .assistant
|
||||
#expect(window.creationActions.map(\.id) == ["host", "group"])
|
||||
#expect(window.creationActions.map(\.id) == ["host", "group", "ssh-folder"])
|
||||
|
||||
window.openPage(.keychain)
|
||||
#expect(window.creationActions.map(\.id) == ["import", "generate", "identity"])
|
||||
#expect(window.creationActions.map(\.id) == ["import", "generate", "identity", "ssh-folder"])
|
||||
|
||||
window.openPage(.snippets)
|
||||
#expect(window.creationActions.map(\.id) == ["snippet", "package"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func sshFolderImportIsLastAndStartsASeparateMenuGroup() {
|
||||
let window = WindowModel()
|
||||
for page in [VaultPage.hosts, .keychain, .knownHosts] {
|
||||
let actions = window.creationActions(for: page)
|
||||
#expect(actions.last?.id == "ssh-folder")
|
||||
#expect(actions.last?.startsGroup == true)
|
||||
let menu = window.creationMenu(for: page, locale: Locale(identifier: "en"))
|
||||
#expect(menu[menu.count - 2].isSeparator)
|
||||
#expect(menu.last?.title == "Import .ssh Folder")
|
||||
}
|
||||
}
|
||||
|
||||
/// Each Vault page keeps its own query, and keeps it across a page switch —
|
||||
/// the field lives in the titlebar and outlives the page under it.
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user