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.
This commit is contained in:
Kaede Akino committed 2026-10-17 09:50:56 +08:00
1 parent 9a36fde226
commit c6d9aad810
14 files changed
+294 -1

No files matched your search

@@ -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)
}
}
+69
View File
@@ -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 {
@@ -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",
@@ -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(
@@ -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,
@@ -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
}
@@ -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)
}
@@ -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."
}
@@ -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)
}
}
}
@@ -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))
+11
View File
@@ -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 {
+9 -1
View File
@@ -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)
}
@@ -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)
+26
View File
@@ -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 {