From c6d9aad810277f3ccfc1d17a91573bb9bb388465 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 09:50:56 +0800 Subject: [PATCH] fix(hosts): validate wake addresses before saving credentials Validate configured MAC addresses in desktop/mobile editors and ordinary, managed, and custom-identity save paths before credential writes. Preserve the Auto Wake preference while editing and add localized invalid-address feedback with regression coverage for rejected writes. Keep production artwork observation unchanged. Diagnostics confirmed an inherited hosted-test static-link identity issue; temporary linker and trace experiments were removed without weakening behavioral assertions. Validation: 83 focused macOS tests passed; iOS MAC cases passed while the two known hosted-test identity/artwork failures remain. --- .../HostCustomIdentityDraftTests.swift | 99 +++++++++++++++++++ FrameworkTests/LocalNetworkTests.swift | 69 +++++++++++++ Frameworks/Resources/Localizable.xcstrings | 47 +++++++++ .../HostCustomIdentityDraft.swift | 1 + .../FeatureModels/HostLibraryModel.swift | 8 ++ .../HostAccessError+Presentation.swift | 2 + .../HostWakeOnLANSection.swift | 5 + .../Strings/LocalNetwork.swift | 3 + .../WakeOnLANError+Presentation.swift | 12 +++ .../HostEditor/MobileHostEditor.swift | 1 + TFITests/MobileWakeAccessTests.swift | 11 +++ TFITests/MobileWorkspaceSessionTests.swift | 10 +- TFM/Features/HostEditor/HostEditorView.swift | 1 + TFMTests/WakeOnLANAccessTests.swift | 26 +++++ 14 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 Frameworks/TermindPresentation/WakeOnLANError+Presentation.swift diff --git a/FrameworkTests/HostCustomIdentityDraftTests.swift b/FrameworkTests/HostCustomIdentityDraftTests.swift index f50d62f6..eaaed3a0 100644 --- a/FrameworkTests/HostCustomIdentityDraftTests.swift +++ b/FrameworkTests/HostCustomIdentityDraftTests.swift @@ -131,4 +131,103 @@ struct HostCustomIdentityDraftTests { #expect(library.host(host.id)?.identityID == nil) #expect(library.host(host.id)?.proxy?.identityID == nil) } + + @Test + func invalidWakePreferencesRejectBeforeChangingIdentitiesOrSecrets() throws { + let secrets = HostDraftRecordingSecrets() + var host = Host( + name: "Server", host: "server.invalid", + proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080)) + host.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true) + let library = HostLibraryModel(hosts: [host], secrets: secrets) + var initial = HostCustomIdentityDraft(host: host, library: library) + initial.sshUsername = "original" + initial.sshPassword = "ssh-original" + initial.selectCustomProxy(on: &host, using: library) + host.proxy?.username = "proxy-original" + initial.proxyPassword = "proxy-original" + try initial.save(into: &host, using: library) + let savedHost = try #require(library.host(host.id)) + let identities = library.identities + let writes = secrets.writeCount + let ssh = try #require(library.customIdentity(for: host.id, purpose: .ssh)) + let proxy = try #require(library.customIdentity(for: host.id, purpose: .proxy)) + + for preferences in [ + WakeOnLANPreferences(macAddress: "", automaticallyWake: true), + WakeOnLANPreferences(macAddress: "02:11:22:33:44:GG", automaticallyWake: true), + WakeOnLANPreferences(macAddress: "02:11:22:33:44:GG"), + ] { + for removesProxy in [false, true] { + var candidate = savedHost + var draft = HostCustomIdentityDraft(host: candidate, library: library) + draft.sshUsername = "unsaved" + draft.sshPassword = "ssh-unsaved" + draft.proxyPassword = "proxy-unsaved" + if removesProxy { candidate.proxy = nil } + candidate.wakeOnLAN = preferences + #expect(throws: WakeOnLANError.self) { + try draft.save(into: &candidate, using: library) + } + #expect(library.host(host.id) == savedHost) + #expect(library.identities == identities) + #expect(secrets.writeCount == writes) + #expect(try library.editablePassword(for: ssh) == "ssh-original") + #expect(try library.editablePassword(for: proxy) == "proxy-original") + } + } + } + + @Test + func invalidWakePreferencesRejectBeforeCreatingIdentityOrSecret() { + let secrets = HostDraftRecordingSecrets() + let library = HostLibraryModel(secrets: secrets) + var host = Host(name: "Server", host: "server.invalid") + host.wakeOnLAN.automaticallyWake = true + var draft = HostCustomIdentityDraft() + draft.sshUsername = "user" + draft.sshPassword = "password" + #expect(throws: WakeOnLANError.self) { + try draft.save(into: &host, using: library) + } + #expect(library.hosts.isEmpty) + #expect(library.identities.isEmpty) + #expect(secrets.writeCount == 0) + #expect(host.identityID == nil) + } +} + +private nonisolated final class HostDraftRecordingSecrets: SecretStoring, @unchecked Sendable { + private let base = InMemorySecretStore() + private let lock = NSLock() + private var writes = 0 + var writeCount: Int { lock.withLock { writes } } + + func string(for account: String, storage: PasswordStorage) throws -> String? { + try base.string(for: account, storage: storage) + } + + func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { + try base.keyCredential(for: account, storage: storage) + } + + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { + lock.withLock { writes += 1 } + try base.setString(value, for: account, storage: storage) + } + + func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws { + lock.withLock { writes += 1 } + try base.setKeyCredential(value, for: account, storage: storage) + } + + func removePassword(_ account: String, storage: PasswordStorage) throws { + lock.withLock { writes += 1 } + try base.removePassword(account, storage: storage) + } + + func removeKeyCredential(_ account: String, storage: KeyStorage) throws { + lock.withLock { writes += 1 } + try base.removeKeyCredential(account, storage: storage) + } } diff --git a/FrameworkTests/LocalNetworkTests.swift b/FrameworkTests/LocalNetworkTests.swift index f839bfa6..328a42ea 100644 --- a/FrameworkTests/LocalNetworkTests.swift +++ b/FrameworkTests/LocalNetworkTests.swift @@ -32,6 +32,43 @@ struct LocalNetworkTests { } } + @Test func invalidWakePreferencesCannotReplaceASavedHost() throws { + var original = Host(name: "Server", host: "server.invalid") + original.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true) + let library = HostLibraryModel(hosts: [original]) + for preferences in [ + WakeOnLANPreferences(macAddress: "", automaticallyWake: true), + WakeOnLANPreferences(macAddress: "02:11:22:33:44:GG", automaticallyWake: true), + WakeOnLANPreferences(macAddress: "02:11:22:33:44:GG"), + ] { + var draft = original + draft.name = "Unsaved" + draft.wakeOnLAN = preferences + #expect(!library.saveHost(draft)) + #expect(library.host(original.id) == original) + #expect(library.storageError != nil) + } + var corrected = original + corrected.wakeOnLAN.macAddress = "02-ab-cd-12-34-56" + #expect(library.saveHost(corrected)) + #expect(library.host(original.id)?.wakeOnLAN.address?.rawValue == "02:AB:CD:12:34:56") + corrected.wakeOnLAN = .init() + #expect(library.saveHost(corrected)) + #expect(library.host(original.id)?.wakeOnLAN == .init()) + #expect(library.storageError == nil) + } + + @Test func invalidWakePreferencesCannotCreateAHost() { + var host = Host(name: "Server", host: "server.invalid") + host.wakeOnLAN.automaticallyWake = true + let library = HostLibraryModel() + #expect(!library.saveHost(host)) + #expect(library.hosts.isEmpty) + host.wakeOnLAN.macAddress = "02:11:22:33:44:55" + #expect(library.saveHost(host)) + #expect(library.host(host.id)?.wakeOnLAN.automaticallyWake == true) + } + @Test func sendingIsFreeAndOnlyAutoUsesCooldown() throws { let sender = RecordingWakeSender() let state = WakeTestState() @@ -261,6 +298,38 @@ struct LocalNetworkTests { old.formatVersion = 6 #expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) } } + + @Test func invalidWakePreferencesCannotMutateManagedHostPreferences() throws { + var group = HostGroup(name: "Tailnet") + group.tailscale = TailscaleGroupConfiguration( + network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net")) + let peer = TailscalePeer( + stableID: "peer", name: "Server", dnsName: "server.test.ts.net", online: true, + sshHostKeys: ["ssh-ed25519 test"]) + let library = HostLibraryModel(groups: [group]) + library.replaceTailscalePeers([peer], groupID: group.id) + var original = try #require(library.hosts.first) + original.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true) + #expect(library.saveTailscaleHost(original, username: "original")) + let savedGroup = try #require(library.group(group.id)) + for preferences in [ + WakeOnLANPreferences(macAddress: "", automaticallyWake: true), + WakeOnLANPreferences(macAddress: "02:11:22:33:44:GG"), + ] { + var draft = original + draft.wakeOnLAN = preferences + draft.iconOverrideID = "cloud" + #expect(!library.saveTailscaleHost(draft, username: "unsaved")) + #expect(!library.saveHost(draft)) + #expect(library.group(group.id) == savedGroup) + #expect(library.host(original.id) == original) + } + original.wakeOnLAN = .init() + #expect(library.saveTailscaleHost(original, username: "corrected")) + library.replaceTailscalePeers([peer], groupID: group.id) + #expect(library.host(original.id)?.wakeOnLAN == .init()) + #expect(library.tailscaleUsername(for: original) == "corrected") + } } private nonisolated final class RecordingWakeSender: WakeOnLANSending, @unchecked Sendable { diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 2f203d6c..d9ac34d7 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -28145,6 +28145,53 @@ } } }, + "Enter a valid unicast MAC address." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geben Sie eine gültige Unicast-MAC-Adresse ein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a valid unicast MAC address." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saisissez une adresse MAC unicast valide." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "有効なユニキャストMACアドレスを入力してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유효한 유니캐스트 MAC 주소를 입력하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入有效的单播MAC地址。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請輸入有效的單播MAC位址。" + } + } + } + }, "Enter a value for %@." : { "comment" : "A description of an error that might occur when parsing an SSH argument. The argument is a flag that needs a value.", "extractionState" : "manual", diff --git a/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift b/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift index 5fdf471b..02cc730a 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift @@ -221,6 +221,7 @@ public struct HostCustomIdentityDraft { } public mutating func save(into host: inout Host, using library: HostLibraryModel) throws { + guard host.wakeOnLAN.isValid else { throw WakeOnLANError.invalidAddress } try library.requireSavingHosts([host]) if usesCustomSSH(for: host) { let identity = try library.saveCustomIdentity( diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 704af367..1f9eaada 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -1076,6 +1076,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { @discardableResult public func saveHost(_ host: Host) -> Bool { + guard host.wakeOnLAN.isValid else { + storageError = WakeOnLANError.invalidAddress.localizedDescription + return false + } do { try requireSavingHosts([host]) } catch HostAccessError.hostLimit { storageError = nil return false @@ -2649,6 +2653,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { } public func saveTailscaleHost(_ host: Host, username: String? = nil) -> Bool { + guard host.wakeOnLAN.isValid else { + storageError = WakeOnLANError.invalidAddress.localizedDescription + return false + } guard let existing = self.host(host.id), existing.isTailscaleManaged, host.tailscale == existing.tailscale, host.groupID == existing.groupID, host.name == existing.name, host.host == existing.host, host.port == 22, diff --git a/Frameworks/TermindPresentation/HostAccessError+Presentation.swift b/Frameworks/TermindPresentation/HostAccessError+Presentation.swift index 9db92775..30b9b4ed 100644 --- a/Frameworks/TermindPresentation/HostAccessError+Presentation.swift +++ b/Frameworks/TermindPresentation/HostAccessError+Presentation.swift @@ -1,4 +1,5 @@ import Foundation +import TermindApplication import TermindDomain // Keep localized copy in Presentation while shared policy failures retain their typed identity. @@ -7,6 +8,7 @@ extension HostAccessError: @retroactive LocalizedError { /// A static reference also keeps this conformance linked in the static Presentation framework. public nonisolated static func presentationDescription(of error: any Error) -> String? { if let accessError = error as? Self { return accessError.errorDescription } + if let wakeError = error as? WakeOnLANError { return wakeError.errorDescription } return error.localizedDescription } diff --git a/Frameworks/TermindPresentation/HostWakeOnLANSection.swift b/Frameworks/TermindPresentation/HostWakeOnLANSection.swift index 660dd4d0..ed9df2d8 100644 --- a/Frameworks/TermindPresentation/HostWakeOnLANSection.swift +++ b/Frameworks/TermindPresentation/HostWakeOnLANSection.swift @@ -34,6 +34,11 @@ public struct HostWakeOnLANSection: View { isOn: WakeOnLANSettingsAccess( invalidAddress: { invalidAddress = true } ).automatic($preferences)) + if !preferences.isValid { + Text(AppStrings.LocalNetwork.invalidMACAddress) + .font(.caption) + .foregroundStyle(.red) + } } header: { Text(AppStrings.LocalNetwork.wakeOnLAN) } diff --git a/Frameworks/TermindPresentation/Strings/LocalNetwork.swift b/Frameworks/TermindPresentation/Strings/LocalNetwork.swift index 192d918c..33b56e83 100644 --- a/Frameworks/TermindPresentation/Strings/LocalNetwork.swift +++ b/Frameworks/TermindPresentation/Strings/LocalNetwork.swift @@ -23,6 +23,9 @@ extension AppStrings { public static var invalidMAC: LocalizedStringResource { "Enter a valid unicast MAC address before enabling Auto Wake-on-LAN." } + public static var invalidMACAddress: LocalizedStringResource { + "Enter a valid unicast MAC address." + } public static var sent: LocalizedStringResource { "Wake request sent. The remote machine may take a moment to become available." } diff --git a/Frameworks/TermindPresentation/WakeOnLANError+Presentation.swift b/Frameworks/TermindPresentation/WakeOnLANError+Presentation.swift new file mode 100644 index 00000000..e83512f8 --- /dev/null +++ b/Frameworks/TermindPresentation/WakeOnLANError+Presentation.swift @@ -0,0 +1,12 @@ +import Foundation +import TermindApplication + +// swift-format-ignore: AvoidRetroactiveConformances +extension WakeOnLANError: @retroactive LocalizedError { + public nonisolated var errorDescription: String? { + switch self { + case .invalidAddress: String(localized: AppStrings.LocalNetwork.invalidMACAddress) + case .noLocalNetwork, .sendFailed: String(localized: AppStrings.LocalNetwork.wakeFailed) + } + } +} diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 6c17d212..537af7d8 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -54,6 +54,7 @@ struct MobileHostEditor: View { || !customIdentity.telnetProxyPassword.isEmpty, canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && draft.preferredTerminalProtocol != nil + && draft.wakeOnLAN.isValid && (!draft.sshEnabled || (draft.terminalTransports.isValid && (1...65_535).contains(draft.sshPort))) && (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort)) && (draft.isTailscaleManaged || customIdentity.isSSHValid(for: draft)) diff --git a/TFITests/MobileWakeAccessTests.swift b/TFITests/MobileWakeAccessTests.swift index 668da608..2a7e8ba6 100644 --- a/TFITests/MobileWakeAccessTests.swift +++ b/TFITests/MobileWakeAccessTests.swift @@ -79,6 +79,17 @@ struct MobileWakeAccessTests { #expect(app.pro.isPaywallPresented) #expect(!configured) } + + @Test func invalidWakeSaveUsesTheSharedMACValidationMessage() throws { + let app = try MobileAppModel(inMemory: true) + var host = TermindDomain.Host(name: "Host", host: "server.invalid") + host.wakeOnLAN.automaticallyWake = true + let message = String(localized: AppStrings.LocalNetwork.invalidMACAddress) + #expect(HostAccessError.presentationDescription(of: WakeOnLANError.invalidAddress) == message) + #expect(!app.library.saveHost(host)) + #expect(app.library.storageError == message) + #expect(app.library.host(host.id) == nil) + } } private nonisolated struct UnexpectedMobileWakeSender: WakeOnLANSending { diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index e2ce629d..bf0de68b 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -4,6 +4,7 @@ import SwiftUI import TermindApplication import TermindDomain import TermindPersistence +import TermindPresentation import Testing import UIKit @@ -37,13 +38,20 @@ struct MobileWorkspaceSessionTests { .systemInfo( RemoteSystemInfo(distributionID: "ubuntu", kernelName: "Linux"))) #expect(workspace.activitySession.iconAsset == "HostOS-windows") - for (choice, symbol) in [("cloud", "cloud"), ("database", "cylinder")] { + for (choice, symbol) in [ + ("cloud", "cloud"), ("database", "cylinder"), ("terminal", "terminal"), + ("database", "cylinder"), ("cloud", "cloud"), + ] { draft = try #require(app.library.host(host.id)) draft.iconOverrideID = choice #expect(app.library.saveHost(draft)) + #expect(app.library.host(host.id)?.iconOverrideID == choice) + #expect(HostIconCatalog.resolvedArtwork(for: app.library.host(host.id)).systemImageName == symbol) for _ in 0..<50 where workspace.activitySession.iconSystemName != symbol { try await Task.sleep(for: .milliseconds(10)) } + #expect(app.library.host(host.id)?.iconOverrideID == choice) + #expect(HostIconCatalog.resolvedArtwork(for: app.library.host(host.id)).systemImageName == symbol) #expect(workspace.activitySession.iconAsset == nil) #expect(workspace.activitySession.iconSystemName == symbol) } diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 21b7c8b1..6749970e 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -717,6 +717,7 @@ struct HostEditorView: View { private var isValid: Bool { !draft.host.trimmingCharacters(in: .whitespaces).isEmpty && draft.preferredTerminalProtocol != nil + && draft.wakeOnLAN.isValid && (!draft.sshEnabled || (draft.terminalTransports.isValid && (1...65_535).contains(draft.sshPort))) && (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort)) && customIdentity.isProxyValid(for: draft) diff --git a/TFMTests/WakeOnLANAccessTests.swift b/TFMTests/WakeOnLANAccessTests.swift index 0805842f..70b27c15 100644 --- a/TFMTests/WakeOnLANAccessTests.swift +++ b/TFMTests/WakeOnLANAccessTests.swift @@ -127,6 +127,32 @@ struct WakeOnLANAccessTests { #expect(invalid) #expect(!preferences.automaticallyWake) } + + @Test func editingAnEnabledWakeAddressPreservesTheToggleUntilCorrectedOrDisabled() { + var preferences = WakeOnLANPreferences(macAddress: "02:11:22:33:44:55", automaticallyWake: true) + let binding = Binding(get: { preferences }, set: { preferences = $0 }) + let automatic = WakeOnLANSettingsAccess(invalidAddress: {}).automatic(binding) + for address in ["02:11:22:33:44:GG", ""] { + binding.macAddress.wrappedValue = address + #expect(automatic.wrappedValue) + #expect(!preferences.isValid) + } + binding.macAddress.wrappedValue = "02-ab-cd-12-34-56" + #expect(preferences.isValid) + binding.macAddress.wrappedValue = "" + automatic.wrappedValue = false + #expect(preferences.isValid) + } + + @Test func invalidWakeSaveUsesTheSharedMACValidationMessage() { + var host = Host(name: "Host", host: "server.invalid") + host.wakeOnLAN.macAddress = "invalid" + let library = HostLibraryModel() + let message = String(localized: AppStrings.LocalNetwork.invalidMACAddress) + #expect(HostAccessError.presentationDescription(of: WakeOnLANError.invalidAddress) == message) + #expect(!library.saveHost(host)) + #expect(library.storageError == message) + } } private nonisolated struct UnexpectedWakeSender: WakeOnLANSending {