Refactor Host Custom Identity editing

This commit is contained in:
Kaede Akino committed 2026-09-21 16:48:52 +08:00
1 parent e59aa2cbc6
commit 97e2362d7d
9 files changed
+530 -236

No files matched your search

@@ -0,0 +1,135 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import Testing
@Suite @MainActor
struct HostCustomIdentityDraftTests {
@Test
func loadsAndSavesSSHAndProxyCustomIdentities() throws {
let secrets = InMemorySecretStore()
var host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
let library = HostLibraryModel(hosts: [host], secrets: secrets)
let ssh = try #require(
try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "ssh-secret", keyID: nil))
let proxy = try #require(
try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
username: "proxy-user", password: "proxy-secret", keyID: nil))
host.identityID = ssh.id
host.proxy?.identityID = proxy.id
host.proxy?.username = proxy.username
#expect(library.saveHost(host))
var draft = HostCustomIdentityDraft(host: host, library: library)
#expect(draft.sshUsername == "deploy")
#expect(draft.sshPassword == "ssh-secret")
#expect(draft.usesCustomProxy)
#expect(draft.proxyPassword == "proxy-secret")
#expect(draft.sshPasswordIsEditable)
#expect(draft.proxyPasswordIsEditable)
#expect(draft.isSSHValid(for: host))
#expect(draft.isProxyValid(for: host))
draft.sshUsername = "root"
draft.proxyPassword = "new-proxy-secret"
try draft.save(into: &host, using: library)
let savedSSH = try #require(library.customIdentity(for: host.id, purpose: .ssh))
let savedProxy = try #require(library.customIdentity(for: host.id, purpose: .proxy))
#expect(savedSSH.id == ssh.id)
#expect(savedSSH.ownerHostID == host.id)
#expect(savedSSH.customPurpose == .ssh)
#expect(savedSSH.username == "root")
#expect(savedProxy.id == proxy.id)
#expect(savedProxy.ownerHostID == host.id)
#expect(savedProxy.customPurpose == .proxy)
#expect(try library.editablePassword(for: savedProxy) == "new-proxy-secret")
#expect(library.host(host.id)?.identityID == savedSSH.id)
#expect(library.host(host.id)?.proxy?.identityID == savedProxy.id)
}
@Test
func reusableSelectionsRetainCustomIdentitiesUntilTheirOwnerOrProxyIsRemoved() throws {
var host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(kind: .socks5, hostname: "proxy.invalid", port: 1080))
let reusable = Identity(name: "Reusable", username: "shared")
let library = HostLibraryModel(hosts: [host], identities: [reusable])
let ssh = try #require(
try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "", keyID: nil))
let proxy = try #require(
try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
username: "proxy-user", password: "secret", keyID: nil))
host.identityID = ssh.id
host.proxy?.identityID = proxy.id
host.proxy?.username = proxy.username
var draft = HostCustomIdentityDraft(host: host, library: library)
draft.selectSSHIdentity(reusable, on: &host)
draft.selectProxyIdentity(reusable, on: &host)
try draft.save(into: &host, using: library)
#expect(library.customIdentity(for: host.id, purpose: .ssh)?.id == ssh.id)
#expect(library.customIdentity(for: host.id, purpose: .proxy)?.id == proxy.id)
#expect(library.host(host.id)?.identityID == reusable.id)
#expect(library.host(host.id)?.proxy?.identityID == reusable.id)
host.proxy = nil
try draft.save(into: &host, using: library)
#expect(library.customIdentity(for: host.id, purpose: .proxy) == nil)
#expect(library.customIdentity(for: host.id, purpose: .ssh)?.id == ssh.id)
}
@Test
func unavailableProxyPasswordAllowsMetadataEditingButNotPasswordEditing() throws {
let hostID = UUID()
let proxy = Identity(
name: "Proxy", username: "proxy-user", credentialRef: "missing-password",
passwordStorage: .thisDevice, ownerHostID: hostID, customPurpose: .proxy)
var host = Host(
id: hostID, name: "Server", host: "server.invalid",
proxy: HostProxy(
kind: .http, hostname: "proxy.invalid", port: 8080,
username: proxy.username, identityID: proxy.id))
let library = HostLibraryModel(
hosts: [host], identities: [proxy], secrets: InMemorySecretStore())
var draft = HostCustomIdentityDraft(host: host, library: library)
#expect(draft.usesCustomProxy)
#expect(!draft.proxyPasswordIsEditable)
#expect(draft.proxyPassword.isEmpty)
#expect(draft.isProxyValid(for: host))
host.proxy?.hostname = "renamed-proxy.invalid"
try draft.save(into: &host, using: library)
#expect(library.host(host.id)?.proxy?.hostname == "renamed-proxy.invalid")
#expect(library.customIdentity(for: host.id, purpose: .proxy)?.credentialRef == "missing-password")
}
@Test
func emptyCustomAuthenticationIsValidAndPersistsNoIdentity() throws {
var host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
let library = HostLibraryModel(hosts: [host])
var draft = HostCustomIdentityDraft(host: host, library: library)
draft.selectCustomSSH(on: &host)
draft.selectCustomProxy(on: &host, using: library)
#expect(draft.isSSHValid(for: host))
#expect(draft.isProxyValid(for: host))
try draft.save(into: &host, using: library)
#expect(library.customIdentity(for: host.id, purpose: .ssh) == nil)
#expect(library.customIdentity(for: host.id, purpose: .proxy) == nil)
#expect(library.host(host.id)?.identityID == nil)
#expect(library.host(host.id)?.proxy?.identityID == nil)
}
}
@@ -0,0 +1,152 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
/// Memory-only Custom Identity fields shared by the native Host editors.
///
/// Passwords live here only for the lifetime of an editor. This value is never
/// persisted, synchronized, logged, or given a textual representation.
@MainActor
public struct HostCustomIdentityDraft {
public var sshUsername = ""
public var sshPassword = ""
public var sshKeyID: SSHKeyRef.ID?
public var usesCustomProxy = false
public var proxyPassword = ""
private var sshIdentityID: Identity.ID?
private var proxyIdentityID: Identity.ID?
private var sshHasConfiguredPassword = false
private var proxyHasConfiguredPassword = false
private var sshPasswordAvailability: SecretAvailability = .notConfigured
private var proxyPasswordAvailability: SecretAvailability = .notConfigured
public init() {}
public init(host: Host, library: HostLibraryModel) {
let ssh = library.customIdentity(for: host.id, purpose: .ssh)
sshIdentityID = ssh?.id
sshUsername = ssh?.username ?? ""
sshKeyID = ssh?.keyID
sshHasConfiguredPassword = ssh?.usesPassword == true
if let ssh {
sshPasswordAvailability = library.passwordAvailability(for: ssh)
if sshPasswordAvailability == .available {
sshPassword = (try? library.editablePassword(for: ssh)) ?? ""
}
}
let proxy = library.customIdentity(for: host.id, purpose: .proxy)
proxyIdentityID = proxy?.id
proxyHasConfiguredPassword = proxy?.usesPassword == true
if let proxy {
proxyPasswordAvailability = library.passwordAvailability(for: proxy)
if host.proxy?.identityID == proxy.id {
usesCustomProxy = true
if proxyPasswordAvailability == .available {
proxyPassword = (try? library.editablePassword(for: proxy)) ?? ""
}
}
}
}
public var sshPasswordIsEditable: Bool {
!sshHasConfiguredPassword || sshPasswordAvailability == .available
}
public var proxyPasswordIsEditable: Bool {
!proxyHasConfiguredPassword || proxyPasswordAvailability == .available
}
public func usesCustomSSH(for host: Host) -> Bool {
guard let identityID = host.identityID else { return true }
return identityID == sshIdentityID
}
public func selectCustomSSH(on host: inout Host) {
host.identityID = sshIdentityID
}
public func selectSSHIdentity(_ identity: Identity, on host: inout Host) {
host.identityID = identity.id
}
public mutating func clearProxyIdentity(on host: inout Host) {
usesCustomProxy = false
host.proxy?.identityID = nil
host.proxy?.username = ""
}
public mutating func selectCustomProxy(on host: inout Host, using library: HostLibraryModel) {
usesCustomProxy = true
let identity = library.customIdentity(for: host.id, purpose: .proxy)
proxyIdentityID = identity?.id
proxyHasConfiguredPassword = identity?.usesPassword == true
proxyPasswordAvailability = identity.map(library.passwordAvailability) ?? .notConfigured
host.proxy?.identityID = identity?.id
host.proxy?.username = identity?.username ?? ""
proxyPassword = ""
if let identity, proxyPasswordAvailability == .available {
proxyPassword = (try? library.editablePassword(for: identity)) ?? ""
}
}
public mutating func selectProxyIdentity(_ identity: Identity, on host: inout Host) {
usesCustomProxy = false
host.proxy?.identityID = identity.id
host.proxy?.username = identity.username
}
public func isSSHValid(for host: Host) -> Bool {
!usesCustomSSH(for: host)
|| (!sshUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| (sshPassword.isEmpty && sshKeyID == nil))
}
public func isProxyValid(for host: Host) -> Bool {
guard let proxy = host.proxy else { return true }
guard usesCustomProxy else { return proxy.isValid }
let username = proxy.username.trimmingCharacters(in: .whitespacesAndNewlines)
let hasPassword = !proxyPassword.isEmpty || proxyHasConfiguredPassword
let hasAuthentication = !username.isEmpty || hasPassword
return !proxy.hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(proxy.port)
&& (!hasAuthentication || (!username.isEmpty && hasPassword))
}
public mutating func save(into host: inout Host, using library: HostLibraryModel) throws {
if usesCustomSSH(for: host) {
let identity = try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: sshUsername, password: sshPassword, keyID: sshKeyID)
host.identityID = identity?.id
sshIdentityID = identity?.id
sshHasConfiguredPassword = identity?.usesPassword == true
sshPasswordAvailability = identity.map(library.passwordAvailability) ?? .notConfigured
}
if usesCustomProxy, let proxy = host.proxy {
let identity = try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "\(host.name) Proxy",
username: proxy.username, password: proxyPassword, keyID: nil)
host.proxy?.identityID = identity?.id
proxyIdentityID = identity?.id
proxyHasConfiguredPassword = identity?.usesPassword == true
proxyPasswordAvailability = identity.map(library.passwordAvailability) ?? .notConfigured
} else if host.proxy == nil {
_ = try library.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "",
username: "", password: "", keyID: nil)
proxyIdentityID = nil
proxyHasConfiguredPassword = false
proxyPasswordAvailability = .notConfigured
}
guard library.saveHost(host) else {
throw TermindSSHError.library(
library.storageError ?? String(localized: "The host could not be saved."))
}
}
}
+20
View File
@@ -257,3 +257,23 @@ never justifies extraction. Keep schemas, native lists/terminal teardown and loc
- **Verification:** macOS and generic iOS Simulator builds passed. Fifty-three focused authentication, Agent and
authorized-Key tests passed; all 381 Framework tests passed. The default macOS run finished with 1,792 passed, 64
opt-in skips and no failures. Changed-file strict formatting, diff checks and Chinese localization passed.
### 2026-09-21 — shared Host Custom Identity editing (complete)
- **Evidence:** `HostEditorView` and `MobileHostEditor` independently retain and load the same Custom SSH/Proxy Identity
fields, decide password editability, validate the same authentication combinations and perform the same Identity-before-
Host save sequence. Host deletion already filters projected and persistent identities by `ownerHostID`, but its tests cover
only one SSH Identity through the projection helper rather than both purposes through app and outbox entry points.
- **Action:** introduce one Application-owned, memory-only Custom Identity draft for both native Host editors; retain their
platform controls, routes and navigation. Add end-to-end deletion coverage for SSH and proxy Custom Identities, their
passwords and unrelated Identity retention, and remove the inaccurate `legacy` prefix from the strict envelope test name.
- **Expected reduction:** delete the duplicated Host-editor loading, validation and saving helpers. Record the measured
production-line change and focused/full verification after implementation; no reduction claim is made while in progress.
- **Result:** one Application-owned `HostCustomIdentityDraft` now serves both native editors without persisting or describing
plaintext. It centralizes selection, availability, validation and Identity-before-Host saves, including metadata edits with
unavailable proxy passwords. Host deletion tests now cover both Custom purposes, Keychain failure reporting, app entry points
and selective SwiftData finalization. Production changed +204/−220 lines (net −16); no schema or localization changed.
- **Verification:** macOS focused Identity/draft/outbox/envelope suites and the 25-test mobile Vault suite passed; TFM and generic
iOS Simulator builds passed. The default macOS run recorded 1,831 passed, 64 opt-in skips and two unrelated concurrent Agent
timing failures; both complete Agent suites passed immediately on focused rerun (35/35). Recursive strict formatting, diff
checks and Chinese localization checks passed.
+28 -116
View File
@@ -26,11 +26,7 @@ struct MobileHostEditor: View {
let original: Host
@State private var draft: Host
@State private var editorRoute: EditorRoute?
@State private var customUsername = ""
@State private var customPassword = ""
@State private var customKeyID: SSHKeyRef.ID?
@State private var proxyUsesCustom = false
@State private var proxyCustomPassword = ""
@State private var customIdentity = HostCustomIdentityDraft()
@State private var didLoadCustom = false
init(app: MobileAppModel, host: Host) {
@@ -42,27 +38,14 @@ struct MobileHostEditor: View {
var body: some View {
MobileEditorSheet(
title: String(localized: "Host"),
isDirty: draft != original || !customUsername.isEmpty || !customPassword.isEmpty || customKeyID != nil,
canSave: MobileHostEditorPolicy.canSave(draft) && customIsValid && proxyIsValid,
isDirty: draft != original || !customIdentity.sshUsername.isEmpty
|| !customIdentity.sshPassword.isEmpty || customIdentity.sshKeyID != nil,
canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(draft.port)
&& customIdentity.isSSHValid(for: draft)
&& customIdentity.isProxyValid(for: draft),
save: {
if usesCustomIdentity {
let identity = try library.saveCustomIdentity(
ownerHostID: draft.id, purpose: .ssh, displayName: draft.name,
username: customUsername, password: customPassword, keyID: customKeyID)
draft.identityID = identity?.id
}
if proxyUsesCustom, let proxy = draft.proxy {
let identity = try library.saveCustomIdentity(
ownerHostID: draft.id, purpose: .proxy, displayName: "\(draft.name) Proxy",
username: proxy.username, password: proxyCustomPassword, keyID: nil)
draft.proxy?.identityID = identity?.id
} else if draft.proxy == nil {
_ = try library.saveCustomIdentity(
ownerHostID: draft.id, purpose: .proxy, displayName: "",
username: "", password: "", keyID: nil)
}
library.upsert(draft)
if let error = library.storageError { throw MobileEditorError.message(error) }
try customIdentity.save(into: &draft, using: library)
},
content: {
HostConnectionFields(host: $draft)
@@ -78,16 +61,16 @@ struct MobileHostEditor: View {
}
if usesCustomIdentity {
LabeledContent("Username") {
TextField("Username", text: $customUsername.singleLineText)
TextField("Username", text: $customIdentity.sshUsername.singleLineText)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent("Password") {
RevealableSecretField("Optional", text: $customPassword)
RevealableSecretField("Optional", text: $customIdentity.sshPassword)
.multilineTextAlignment(.trailing)
.disabled(!customPasswordEditable)
.disabled(!customIdentity.sshPasswordIsEditable)
}
Picker("Key", selection: $customKeyID) {
Picker("Key", selection: $customIdentity.sshKeyID) {
Text("None").tag(SSHKeyRef.ID?.none)
ForEach(library.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) }
}
@@ -133,16 +116,16 @@ struct MobileHostEditor: View {
Text("New Identity").tag(Choice<Identity.ID>.new)
}
.accessibilityLabel("Proxy Identity")
if proxyUsesCustom {
if customIdentity.usesCustomProxy {
LabeledContent("Username") {
TextField("Username", text: proxyString(\.username).singleLineText)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent("Password") {
RevealableSecretField("Required", text: $proxyCustomPassword)
RevealableSecretField("Required", text: $customIdentity.proxyPassword)
.multilineTextAlignment(.trailing)
.disabled(!proxyCustomPasswordEditable)
.disabled(!customIdentity.proxyPasswordIsEditable)
}
} else if draft.proxy?.identityID != nil {
LabeledContent("Username") {
@@ -192,7 +175,7 @@ struct MobileHostEditor: View {
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage)
) { identity in
draft = MobileHostEditorPolicy.selectIdentity(identity, current: draft)
customIdentity.selectSSHIdentity(identity, on: &draft)
}
case .proxyIdentity:
MobileIdentityEditor(
@@ -202,7 +185,7 @@ struct MobileHostEditor: View {
passwordStorage: app.newVaultPasswordStorage),
configuration: .proxy
) { identity in
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: draft.proxy)
customIdentity.selectProxyIdentity(identity, on: &draft)
}
case .group:
MobileHostGroupEditor(app: app, group: nil, parentID: nil) { group in
@@ -220,8 +203,10 @@ struct MobileHostEditor: View {
} set: { choice in
switch choice {
case .unset, .custom:
draft.identityID = library.customIdentity(for: draft.id, purpose: .ssh)?.id
case .existing(let id): draft.identityID = id
customIdentity.selectCustomSSH(on: &draft)
case .existing(let id):
guard let identity = library.identity(id) else { return }
customIdentity.selectSSHIdentity(identity, on: &draft)
case .new: editorRoute = .hostIdentity
}
}
@@ -271,21 +256,17 @@ struct MobileHostEditor: View {
private var proxyIdentityChoice: Binding<Choice<Identity.ID>> {
Binding {
if proxyUsesCustom { return .custom }
if customIdentity.usesCustomProxy { return .custom }
return draft.proxy?.identityID.map(Choice.existing) ?? .unset
} set: { choice in
switch choice {
case .unset:
proxyUsesCustom = false
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(nil, current: draft.proxy)
customIdentity.clearProxyIdentity(on: &draft)
case .custom:
proxyUsesCustom = true
let custom = library.customIdentity(for: draft.id, purpose: .proxy)
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(custom, current: draft.proxy)
loadProxyPassword(custom)
customIdentity.selectCustomProxy(on: &draft, using: library)
case .existing(let id):
proxyUsesCustom = false
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(library.identity(id), current: draft.proxy)
guard let identity = library.identity(id) else { return }
customIdentity.selectProxyIdentity(identity, on: &draft)
case .new:
editorRoute = .proxyIdentity
}
@@ -297,81 +278,18 @@ struct MobileHostEditor: View {
}
private var usesCustomIdentity: Bool {
guard let id = draft.identityID else { return true }
return library.identity(id)?.isHostCustom == true
}
private var customPasswordEditable: Bool {
guard let identity = library.customIdentity(for: draft.id, purpose: .ssh), identity.usesPassword else {
return true
}
return library.passwordAvailability(for: identity) == .available
}
private var proxyCustomPasswordEditable: Bool {
guard let identity = library.customIdentity(for: draft.id, purpose: .proxy), identity.usesPassword else {
return true
}
return library.passwordAvailability(for: identity) == .available
}
private var customIsValid: Bool {
!usesCustomIdentity
|| (!customUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| (customPassword.isEmpty && customKeyID == nil))
}
private var proxyIsValid: Bool {
guard let proxy = draft.proxy else { return true }
guard proxyUsesCustom else { return proxy.isValid }
let empty =
proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& proxyCustomPassword.isEmpty
return !proxy.hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(proxy.port)
&& (empty
|| (!proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !proxyCustomPassword.isEmpty))
customIdentity.usesCustomSSH(for: draft)
}
private func loadCustomOnce() {
guard !didLoadCustom else { return }
didLoadCustom = true
let custom = library.customIdentity(for: draft.id, purpose: .ssh)
customUsername = custom?.username ?? ""
customKeyID = custom?.keyID
if let custom, case .available = library.passwordAvailability(for: custom) {
customPassword = (try? library.editablePassword(for: custom)) ?? ""
}
if let custom = library.customIdentity(for: draft.id, purpose: .proxy),
draft.proxy?.identityID == custom.id
{
proxyUsesCustom = true
loadProxyPassword(custom)
}
}
private func loadProxyPassword(_ identity: Identity?) {
proxyCustomPassword = ""
guard let identity, case .available = library.passwordAvailability(for: identity) else { return }
proxyCustomPassword = (try? library.editablePassword(for: identity)) ?? ""
customIdentity = HostCustomIdentityDraft(host: draft, library: library)
}
}
nonisolated enum MobileHostEditorPolicy {
static func selectIdentity(_ identity: Identity, current: Host) -> Host {
var host = current
host.identityID = identity.id
return host
}
static func canSave(_ host: Host) -> Bool {
!host.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(host.port)
&& (host.proxy?.isValid ?? true)
}
static func selectProxy(
_ kind: HostProxy.ProtocolKind?, current: HostProxy?
) -> HostProxy? {
@@ -387,12 +305,6 @@ nonisolated enum MobileHostEditorPolicy {
return proxy
}
static func selectProxyIdentity(_ identity: Identity?, current: HostProxy?) -> HostProxy? {
guard var proxy = current else { return nil }
proxy.identityID = identity?.id
proxy.username = identity?.username ?? ""
return proxy
}
}
nonisolated enum MobileEditorError: LocalizedError {
+41 -12
View File
@@ -178,16 +178,18 @@ struct MobileVaultFeatureTests {
@Test func hostProxyEditorAppliesDefaultsPreservesCustomPortsAndValidatesAuthentication() {
var host = TermindDomain.Host(name: "Remote", host: "example.invalid")
#expect(MobileHostEditorPolicy.canSave(host))
let library = HostLibraryModel(hosts: [host])
var identityDraft = HostCustomIdentityDraft(host: host, library: library)
#expect(identityDraft.isProxyValid(for: host))
host.proxy = MobileHostEditorPolicy.selectProxy(.http, current: nil)
#expect(host.proxy?.port == 8080)
#expect(!MobileHostEditorPolicy.canSave(host))
#expect(!identityDraft.isProxyValid(for: host))
host.proxy?.hostname = "proxy.example"
#expect(MobileHostEditorPolicy.canSave(host))
#expect(identityDraft.isProxyValid(for: host))
host.proxy?.port = 0
#expect(!MobileHostEditorPolicy.canSave(host))
#expect(!identityDraft.isProxyValid(for: host))
host.proxy?.port = 8080
host.proxy = MobileHostEditorPolicy.selectProxy(.socks5, current: host.proxy)
#expect(host.proxy?.port == 1080)
@@ -199,17 +201,17 @@ struct MobileVaultFeatureTests {
let identity = Identity(
name: "Proxy", username: "proxy-user", credentialRef: "termind.password.proxy")
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: host.proxy)
identityDraft.selectProxyIdentity(identity, on: &host)
#expect(host.proxy?.identityID == identity.id)
#expect(host.proxy?.username == "proxy-user")
#expect(MobileHostEditorPolicy.canSave(host))
#expect(identityDraft.isProxyValid(for: host))
host.proxy?.username = ""
#expect(!MobileHostEditorPolicy.canSave(host))
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(nil, current: host.proxy)
#expect(!identityDraft.isProxyValid(for: host))
identityDraft.clearProxyIdentity(on: &host)
#expect(host.proxy?.identityID == nil)
#expect(host.proxy?.username.isEmpty == true)
#expect(MobileHostEditorPolicy.canSave(host))
#expect(identityDraft.isProxyValid(for: host))
host.proxy = MobileHostEditorPolicy.selectProxy(nil, current: host.proxy)
#expect(host.proxy == nil)
@@ -296,9 +298,10 @@ struct MobileVaultFeatureTests {
#expect(host.identityID == originalIdentity.id)
#expect(host.proxy?.identityID == originalIdentity.id)
host = MobileHostEditorPolicy.selectIdentity(createdIdentity, current: host)
var identityDraft = HostCustomIdentityDraft()
identityDraft.selectSSHIdentity(createdIdentity, on: &host)
#expect(host.identityID == createdIdentity.id)
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(createdIdentity, current: host.proxy)
identityDraft.selectProxyIdentity(createdIdentity, on: &host)
#expect(host.proxy?.identityID == createdIdentity.id)
#expect(host.proxy?.username == createdIdentity.username)
}
@@ -612,12 +615,37 @@ struct MobileVaultFeatureTests {
@Test func deletingAHostCompletesDurableCleanup() async throws {
let app = try MobileAppModel(inMemory: true)
defer { app.outbox.stop() }
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
var host = TermindDomain.Host(
name: "Remote", host: "example.invalid",
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
app.library.upsert(host)
let dependent = TermindDomain.Host(name: "Dependent", host: "dependent.invalid", jumpHostID: host.id)
app.library.upsert(dependent)
let reusable = Identity(name: "Reusable", username: "shared")
app.library.upsert(reusable)
let ssh = try #require(
try app.library.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "ssh-secret", keyID: nil))
let proxy = try #require(
try app.library.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Remote Proxy",
username: "proxy", password: "proxy-secret", keyID: nil))
let retained = try #require(
try app.library.saveCustomIdentity(
ownerHostID: dependent.id, purpose: .ssh, displayName: dependent.name,
username: "dependent", password: "retained-secret", keyID: nil))
host.identityID = ssh.id
host.proxy?.identityID = proxy.id
host.proxy?.username = proxy.username
#expect(app.library.saveHost(host))
app.deleteHost(host.id)
#expect(app.library.host(host.id) == nil)
#expect(app.library.identity(ssh.id) == nil)
#expect(app.library.identity(proxy.id) == nil)
#expect(app.library.identity(reusable.id) == reusable)
#expect(app.library.identity(retained.id)?.ownerHostID == dependent.id)
var finished = false
for _ in 0..<100 {
let context = ModelContext(app.container)
@@ -632,6 +660,7 @@ struct MobileVaultFeatureTests {
let saved = try HostPersistence(context: ModelContext(app.container)).load()
#expect(!saved.hosts.contains(where: { $0.id == host.id }))
#expect(saved.hosts.first(where: { $0.id == dependent.id })?.jumpHostID == nil)
#expect(Set(saved.identities.map(\.id)) == [reusable.id, retained.id])
}
}
+24 -104
View File
@@ -1,4 +1,5 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
@@ -25,11 +26,7 @@ struct HostEditorView: View {
@State private var route: Route?
@State private var saveError: String?
@State private var didLoad = false
@State private var customUsername = ""
@State private var customPassword = ""
@State private var customKeyID: SSHKeyRef.ID?
@State private var proxyUsesCustom = false
@State private var proxyCustomPassword = ""
@State private var customIdentity = HostCustomIdentityDraft()
init(hostID: Host.ID?, initialGroupID: HostGroup.ID? = nil) {
self.hostID = hostID
@@ -93,13 +90,13 @@ struct HostEditorView: View {
}
if usesCustomIdentity {
TextField("Username", text: $customUsername.singleLineText, axis: .horizontal)
TextField("Username", text: $customIdentity.sshUsername.singleLineText, axis: .horizontal)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
RevealableSecretField("Password", text: $customPassword, prompt: Text("Optional"))
RevealableSecretField("Password", text: $customIdentity.sshPassword, prompt: Text("Optional"))
.padding(.leading, Metrics.Space.xxl)
.disabled(!customPasswordEditable)
Picker("Key", selection: $customKeyID) {
.disabled(!customIdentity.sshPasswordIsEditable)
Picker("Key", selection: $customIdentity.sshKeyID) {
Text("None").tag(SSHKeyRef.ID?.none)
ForEach(app.hostLibrary.keys) { key in
Text(key.label).tag(SSHKeyRef.ID?.some(key.id))
@@ -155,14 +152,14 @@ struct HostEditorView: View {
}
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel("Proxy Identity")
if proxyUsesCustom {
if customIdentity.usesCustomProxy {
TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel("Proxy Username")
RevealableSecretField("Password", text: $proxyCustomPassword, prompt: Text("Required"))
RevealableSecretField("Password", text: $customIdentity.proxyPassword, prompt: Text("Required"))
.padding(.leading, Metrics.Space.xxl)
.disabled(!proxyCustomPasswordEditable)
.disabled(!customIdentity.proxyPasswordIsEditable)
} else if draft.proxy?.identityID != nil {
TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal)
.singleLinePaste()
@@ -285,8 +282,7 @@ struct HostEditorView: View {
) {
identity, passwordUpdate in
try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate)
draft.proxy?.identityID = identity.id
draft.proxy?.username = identity.username
customIdentity.selectProxyIdentity(identity, on: &draft)
}
}
@@ -325,8 +321,10 @@ struct HostEditorView: View {
} set: { choice in
switch choice {
case .unset, .custom:
draft.identityID = app.hostLibrary.customIdentity(for: draft.id, purpose: .ssh)?.id
case .existing(let id): draft.identityID = id
customIdentity.selectCustomSSH(on: &draft)
case .existing(let id):
guard let identity = app.hostLibrary.identity(id) else { return }
customIdentity.selectSSHIdentity(identity, on: &draft)
case .new: route = .identity
}
}
@@ -443,25 +441,17 @@ struct HostEditorView: View {
private var proxyIdentityChoice: Binding<Choice<Identity.ID>> {
Binding {
if proxyUsesCustom { return .custom }
if customIdentity.usesCustomProxy { return .custom }
return draft.proxy?.identityID.map(Choice.existing) ?? .unset
} set: { choice in
switch choice {
case .unset:
proxyUsesCustom = false
draft.proxy?.identityID = nil
draft.proxy?.username = ""
customIdentity.clearProxyIdentity(on: &draft)
case .custom:
proxyUsesCustom = true
let custom = app.hostLibrary.customIdentity(for: draft.id, purpose: .proxy)
draft.proxy?.identityID = custom?.id
draft.proxy?.username = custom?.username ?? ""
loadProxyPassword(custom)
customIdentity.selectCustomProxy(on: &draft, using: app.hostLibrary)
case .existing(let id):
proxyUsesCustom = false
draft.proxy?.identityID = id
guard let identity = app.hostLibrary.identity(id) else { return }
draft.proxy?.username = identity.username
customIdentity.selectProxyIdentity(identity, on: &draft)
case .new:
route = .proxyIdentity
}
@@ -473,20 +463,7 @@ struct HostEditorView: View {
}
private var usesCustomIdentity: Bool {
guard let id = draft.identityID else { return true }
return app.hostLibrary.identity(id)?.isHostCustom == true
}
private var customPasswordEditable: Bool {
guard let identity = app.hostLibrary.customIdentity(for: draft.id, purpose: .ssh), identity.usesPassword
else { return true }
return app.hostLibrary.passwordAvailability(for: identity) == .available
}
private var proxyCustomPasswordEditable: Bool {
guard let identity = app.hostLibrary.customIdentity(for: draft.id, purpose: .proxy), identity.usesPassword
else { return true }
return app.hostLibrary.passwordAvailability(for: identity) == .available
customIdentity.usesCustomSSH(for: draft)
}
private var footer: some View {
@@ -521,8 +498,8 @@ struct HostEditorView: View {
private var isValid: Bool {
!draft.host.trimmingCharacters(in: .whitespaces).isEmpty
&& (1...65_535).contains(draft.port)
&& proxyIsValid
&& customIsValid
&& customIdentity.isProxyValid(for: draft)
&& customIdentity.isSSHValid(for: draft)
&& (draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| draft.sftpChannelInitialPath.hasPrefix("/"))
&& app.hostLibrary.validatedRoute(for: draft) != nil
@@ -537,18 +514,7 @@ struct HostEditorView: View {
guard !didLoad else { return }
didLoad = true
load()
let custom = app.hostLibrary.customIdentity(for: draft.id, purpose: .ssh)
customUsername = custom?.username ?? ""
customKeyID = custom?.keyID
if let custom, case .available = app.hostLibrary.passwordAvailability(for: custom) {
customPassword = (try? app.hostLibrary.editablePassword(for: custom)) ?? ""
}
if let custom = app.hostLibrary.customIdentity(for: draft.id, purpose: .proxy),
draft.proxy?.identityID == custom.id
{
proxyUsesCustom = true
loadProxyPassword(custom)
}
customIdentity = HostCustomIdentityDraft(host: draft, library: app.hostLibrary)
}
/// No tag bookkeeping here: `TagField` writes chips straight into
@@ -557,58 +523,12 @@ struct HostEditorView: View {
let initialPath = draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines)
draft.sftpChannelInitialPath = initialPath.isEmpty ? "" : RemotePath.normalized(initialPath)
do {
if usesCustomIdentity {
let identity = try app.hostLibrary.saveCustomIdentity(
ownerHostID: draft.id, purpose: .ssh, displayName: draft.name,
username: customUsername, password: customPassword, keyID: customKeyID)
draft.identityID = identity?.id
}
if proxyUsesCustom, let proxy = draft.proxy {
let identity = try app.hostLibrary.saveCustomIdentity(
ownerHostID: draft.id, purpose: .proxy, displayName: "\(draft.name) Proxy",
username: proxy.username, password: proxyCustomPassword, keyID: nil)
draft.proxy?.identityID = identity?.id
} else if draft.proxy == nil {
_ = try app.hostLibrary.saveCustomIdentity(
ownerHostID: draft.id, purpose: .proxy, displayName: "",
username: "", password: "", keyID: nil)
}
try customIdentity.save(into: &draft, using: app.hostLibrary)
} catch {
saveError = error.localizedDescription
return
}
if app.hostLibrary.saveHost(draft) {
closeTab()
} else {
saveError = app.hostLibrary.storageError ?? String(localized: "The host could not be saved.")
}
}
private var customIsValid: Bool {
!usesCustomIdentity
|| (!customUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| (customPassword.isEmpty && customKeyID == nil))
}
private var proxyIsValid: Bool {
guard let proxy = draft.proxy else { return true }
if proxyUsesCustom {
let empty =
proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& proxyCustomPassword.isEmpty
return !proxy.hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(proxy.port)
&& (empty
|| (!proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !proxyCustomPassword.isEmpty))
}
return proxy.isValid
}
private func loadProxyPassword(_ identity: Identity?) {
proxyCustomPassword = ""
guard let identity, case .available = app.hostLibrary.passwordAvailability(for: identity) else { return }
proxyCustomPassword = (try? app.hostLibrary.editablePassword(for: identity)) ?? ""
closeTab()
}
private func cancel() {
+1 -1
View File
@@ -37,7 +37,7 @@ struct AuthorizedKeyExportTests {
}
@Test
func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws {
func privateKeyEnvelopeRequiresACompleteJSONObject() throws {
var encoded = try JSONEncoder().encode(
SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----")
)
+115 -3
View File
@@ -21,17 +21,31 @@ struct IdentityCredentialTests {
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
defaults.set(true, forKey: SettingsKey.vaultSyncEnabled)
let host = Host(name: "Server", host: "example.com")
let store = HostLibraryModel(hosts: [host], secrets: keychain, localDefaults: defaults)
let otherHost = Host(name: "Other", host: "other.example.com")
let reusable = Identity(name: "Reusable", username: "shared")
let store = HostLibraryModel(
hosts: [host, otherHost], identities: [reusable], secrets: keychain,
localDefaults: defaults)
let result = try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "secret", keyID: nil)
let saved = try #require(result)
let proxy = try #require(
try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
username: "proxy", password: "proxy-secret", keyID: nil))
let retained = try #require(
try store.saveCustomIdentity(
ownerHostID: otherHost.id, purpose: .ssh, displayName: otherHost.name,
username: "other", password: "other-secret", keyID: nil))
#expect(saved.isHostCustom)
#expect(saved.passwordStorage == .thisDevice)
#expect(store.reusableIdentities.isEmpty)
#expect(store.searchIdentities("").isEmpty)
#expect(proxy.isHostCustom)
#expect(store.reusableIdentities == [reusable])
#expect(store.searchIdentities("") == [reusable])
#expect(try keychain.string(for: saved.credentialRef, storage: .thisDevice) == "secret")
#expect(try keychain.string(for: proxy.credentialRef, storage: .thisDevice) == "proxy-secret")
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
try store.migrateCustomPasswordsToICloud()
@@ -41,7 +55,75 @@ struct IdentityCredentialTests {
store.projectHostDeletion(host.id)
#expect(store.identity(saved.id) == nil)
#expect(store.identity(proxy.id) == nil)
#expect(store.identity(reusable.id) == reusable)
let retainedAfterDeletion = try #require(store.identity(retained.id))
#expect(retainedAfterDeletion.ownerHostID == otherHost.id)
#expect(try keychain.string(for: saved.credentialRef, storage: .iCloud) == nil)
#expect(try keychain.string(for: proxy.credentialRef, storage: .iCloud) == nil)
}
@Test
func desktopHostDeletionRemovesBothCustomIdentityPurposesThroughTheAppEntryPoint() throws {
let keychain = InMemorySecretStore()
var host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
let otherHost = Host(name: "Other", host: "other.invalid")
let reusable = Identity(name: "Reusable", username: "shared")
let store = HostLibraryModel(
hosts: [host, otherHost], identities: [reusable], secrets: keychain)
let app = AppModel(hosts: store)
defer { app.shutdownForTermination() }
let ssh = try #require(
try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "ssh-secret", keyID: nil))
let proxy = try #require(
try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
username: "proxy", password: "proxy-secret", keyID: nil))
let retained = try #require(
try store.saveCustomIdentity(
ownerHostID: otherHost.id, purpose: .ssh, displayName: otherHost.name,
username: "other", password: "other-secret", keyID: nil))
host.identityID = ssh.id
host.proxy?.identityID = proxy.id
host.proxy?.username = proxy.username
#expect(store.saveHost(host))
#expect(app.requestHostDeletion(host.id))
#expect(store.identity(ssh.id) == nil)
#expect(store.identity(proxy.id) == nil)
#expect(store.identity(reusable.id) == reusable)
#expect(store.identity(retained.id)?.ownerHostID == otherHost.id)
#expect(try keychain.string(for: ssh.credentialRef, storage: ssh.passwordStorage) == nil)
#expect(try keychain.string(for: proxy.credentialRef, storage: proxy.passwordStorage) == nil)
#expect(
try keychain.string(for: retained.credentialRef, storage: retained.passwordStorage)
== "other-secret")
}
@Test
func failedCustomPasswordRemovalReportsTheErrorWithoutRestoringTheDeletedHost() throws {
let host = Host(name: "Server", host: "server.invalid")
let identity = Identity(
name: "Server", username: "deploy", credentialRef: "owned-password",
passwordStorage: .thisDevice, ownerHostID: host.id, customPurpose: .ssh)
let secrets = FailingPasswordRemovalStore()
try secrets.base.setString(
"secret", for: identity.credentialRef, storage: identity.passwordStorage)
let store = HostLibraryModel(
hosts: [host], identities: [identity], secrets: secrets)
store.projectHostDeletion(host.id)
#expect(store.host(host.id) == nil)
#expect(store.identity(identity.id) == nil)
#expect(store.storageError != nil)
#expect(
try secrets.base.string(
for: identity.credentialRef, storage: identity.passwordStorage) == "secret")
}
@Test
@@ -290,3 +372,33 @@ struct IdentityCredentialTests {
.shouldRequestAuthentication)
}
}
private nonisolated final class FailingPasswordRemovalStore: SecretStoring, @unchecked Sendable {
let base = InMemorySecretStore()
func string(for account: String, storage: PasswordStorage) throws -> String? {
try base.string(for: account, storage: storage)
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
try base.setString(value, for: account, storage: storage)
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
try base.keyCredential(for: account, storage: storage)
}
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
try base.setKeyCredential(value, for: account, storage: storage)
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
throw CocoaError(.fileWriteNoPermission)
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
try base.removeKeyCredential(account, storage: storage)
}
}
+14
View File
@@ -178,8 +178,18 @@ struct RepositoryOutboxTests {
var snippet = Snippet(title: "Deploy", body: "deploy")
snippet.targetHostIDs = [doomed.id, dependent.id]
let rule = PortForwardingRule(hostID: doomed.id)
let doomedSSH = Identity(
name: "Jump", username: "deploy", ownerHostID: doomed.id, customPurpose: .ssh)
let doomedProxy = Identity(
name: "Jump Proxy", username: "proxy", ownerHostID: doomed.id, customPurpose: .proxy)
let reusable = Identity(name: "Reusable", username: "shared")
let retainedCustom = Identity(
name: "App", username: "app", ownerHostID: dependent.id, customPurpose: .ssh)
context.insert(PersistentHost(doomed))
context.insert(PersistentHost(dependent))
for identity in [doomedSSH, doomedProxy, reusable, retainedCustom] {
context.insert(PersistentIdentity(identity))
}
context.insert(PersistentSnippet(snippet))
context.insert(PersistentPortForwardingRule(rule))
context.insert(
@@ -232,6 +242,10 @@ struct RepositoryOutboxTests {
#expect(try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentPendingDeletion>()).isEmpty)
#expect(
Set(try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.id))
== [reusable.id, retainedCustom.id]
)
}
@Test