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:
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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user