Files
Termind/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift
T
2026-09-19 12:30:16 +08:00

112 lines
3.8 KiB
Swift

import Foundation
/// Server-authored authentication data. Neither challenges nor answers belong
/// in an Agent transcript, connection log, or persistent credential store.
public nonisolated struct SSHKeyboardInteractiveChallenge: Identifiable, Hashable, Sendable {
public struct Prompt: Hashable, Sendable {
public var text: String
public var echo: Bool
public init(text: String, echo: Bool) {
self.text = text
self.echo = echo
}
}
public var id: UUID
public var connectionID: UUID
public var hostID: UUID
public var hostname: String
public var port: Int
public var username: String
public var round: Int
public var name: String
public var instruction: String
public var prompts: [Prompt]
public var address: String {
let host = hostname.contains(":") ? "[\(hostname)]" : hostname
return "\(username)@\(host):\(port)"
}
public init(
id: UUID = UUID(), connectionID: UUID, hostID: UUID, hostname: String,
port: Int, username: String, round: Int, name: String, instruction: String,
prompts: [Prompt]
) {
self.id = id
self.connectionID = connectionID
self.hostID = hostID
self.hostname = hostname
self.port = port
self.username = username
self.round = round
self.name = name
self.instruction = instruction
self.prompts = prompts
}
}
public typealias SSHKeyboardInteractiveHandler =
@Sendable (SSHKeyboardInteractiveChallenge) async throws -> [String]
/// An operation-owned queue can also be retained by an app for independent
/// transfers. Cancellation removes exactly the requesting connection's prompt.
@Observable
public final class SSHKeyboardInteractiveQueue {
public private(set) var pending: [SSHKeyboardInteractiveChallenge] = []
private var waiters: [UUID: CheckedContinuation<[String], any Error>] = [:]
public var current: SSHKeyboardInteractiveChallenge? { pending.first }
public init() {}
public nonisolated var handler: SSHKeyboardInteractiveHandler {
{ [weak self] challenge in
guard let self else { throw CancellationError() }
return try await self.request(challenge)
}
}
public func request(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] {
try Task.checkCancellation()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
guard !Task.isCancelled else {
continuation.resume(throwing: CancellationError())
return
}
waiters[challenge.id] = continuation
pending.append(challenge)
if challenge.prompts.isEmpty {
Task { [weak self] in
await Task.yield()
self?.respond(challenge.id, answers: [])
}
}
}
} onCancel: {
Task { @MainActor [weak self] in self?.cancel(challenge.id) }
}
}
public func respond(_ id: UUID, answers: [String]) {
guard let challenge = pending.first(where: { $0.id == id }),
answers.count == challenge.prompts.count
else { return }
pending.removeAll { $0.id == id }
waiters.removeValue(forKey: id)?.resume(returning: answers)
}
public func cancel(_ id: UUID) {
pending.removeAll { $0.id == id }
waiters.removeValue(forKey: id)?.resume(throwing: CancellationError())
}
public func cancelAll(hostID: UUID? = nil) {
for challenge in pending where hostID == nil || challenge.hostID == hostID {
cancel(challenge.id)
}
}
}