diff --git a/FrameworkTests/HostCustomIdentityDraftTests.swift b/FrameworkTests/HostCustomIdentityDraftTests.swift new file mode 100644 index 00000000..fc79b51f --- /dev/null +++ b/FrameworkTests/HostCustomIdentityDraftTests.swift @@ -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) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift b/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift new file mode 100644 index 00000000..ce089f76 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift @@ -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.")) + } + } +} diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 49daa1da..6204e0b0 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -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. diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 5fe996b6..956ecb32 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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.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> { 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 { diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 62e8f617..12d86140 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -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]) } } diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 6565f47c..433c9920 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -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> { 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() { diff --git a/TFMTests/AuthorizedKeyExportTests.swift b/TFMTests/AuthorizedKeyExportTests.swift index e7be522c..0adc2344 100644 --- a/TFMTests/AuthorizedKeyExportTests.swift +++ b/TFMTests/AuthorizedKeyExportTests.swift @@ -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-----") ) diff --git a/TFMTests/IdentityCredentialTests.swift b/TFMTests/IdentityCredentialTests.swift index ef6bc87e..c2475326 100644 --- a/TFMTests/IdentityCredentialTests.swift +++ b/TFMTests/IdentityCredentialTests.swift @@ -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) + } +} diff --git a/TFMTests/RepositoryOutboxTests.swift b/TFMTests/RepositoryOutboxTests.swift index f9d848d0..932efc47 100644 --- a/TFMTests/RepositoryOutboxTests.swift +++ b/TFMTests/RepositoryOutboxTests.swift @@ -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()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) + #expect( + Set(try context.fetch(FetchDescriptor()).map(\.id)) + == [reusable.id, retainedCustom.id] + ) } @Test