From 6d743d53e8069ebd2173b17832dbea4f1f0c10c9 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Thu, 27 Aug 2026 09:16:14 +0800 Subject: [PATCH] gterm: fix SSH, LLM, and persistence defects from audit Reject invalid LLM provider URLs instead of sending keys to OpenAI, keep completion suffixes and command sanitizer output intact, skip unusable SSH keys, and drop session/port-forward work that races past stop. Filter obvious secrets from command history and reject out-of-range SSH ports. --- Sources/LLM/CommandAssistant.swift | 18 +++- Sources/LLM/Completion.swift | 26 ++++-- Sources/LLM/LLMProvider.swift | 28 ++++-- Sources/Model/CommandHistory.swift | 51 +++++++++-- Sources/SSH/ChannelCloseOnce.swift | 18 ++++ Sources/SSH/KnownHosts.swift | 21 ++++- Sources/SSH/PTYChannelHandler.swift | 5 +- Sources/SSH/PortForwardManager.swift | 16 ++++ Sources/SSH/SSHKeyParser.swift | 20 ++++- Sources/SSH/SSHPort.swift | 10 +++ Sources/SSH/SSHSession.swift | 69 ++++++++------- Sources/SSH/SessionStateNotifier.swift | 47 ++++++++++ Sources/UI/AddConnectionView.swift | 4 +- Tests/ChannelCloseOnceTests.swift | 29 ++++++ Tests/CommandAssistantTests.swift | 17 ++++ Tests/CommandHistoryTests.swift | 66 ++++++++++++++ Tests/CompletionTests.swift | 18 ++++ Tests/KnownHostsTests.swift | 47 ++++++++++ Tests/ProviderRequestTests.swift | 33 +++++-- Tests/SSHKeyParserTests.swift | 118 +++++++++++++++++++++++++ Tests/SSHPortTests.swift | 20 +++++ Tests/SessionStateNotifierTests.swift | 65 ++++++++++++++ project.yml | 11 +++ 23 files changed, 681 insertions(+), 76 deletions(-) create mode 100644 Sources/SSH/ChannelCloseOnce.swift create mode 100644 Sources/SSH/SSHPort.swift create mode 100644 Sources/SSH/SessionStateNotifier.swift create mode 100644 Tests/ChannelCloseOnceTests.swift create mode 100644 Tests/CommandHistoryTests.swift create mode 100644 Tests/KnownHostsTests.swift create mode 100644 Tests/SSHKeyParserTests.swift create mode 100644 Tests/SSHPortTests.swift create mode 100644 Tests/SessionStateNotifierTests.swift diff --git a/Sources/LLM/CommandAssistant.swift b/Sources/LLM/CommandAssistant.swift index fa49f12..e15a030 100644 --- a/Sources/LLM/CommandAssistant.swift +++ b/Sources/LLM/CommandAssistant.swift @@ -71,7 +71,7 @@ enum CommandSanitizer { var inside = false var collected: [String] = [] for line in lines { - if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + if line.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("```") { if inside { return collected.joined(separator: "\n") } // closing fence inside = true continue @@ -85,18 +85,28 @@ enum CommandSanitizer { let lines = text.split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) .map(stripLine) - .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } private static func stripLine(_ line: String) -> String { - var s = line.trimmingCharacters(in: .whitespaces) + var s = line.trimmingCharacters(in: .whitespacesAndNewlines) s = s.trimmingCharacters(in: CharacterSet(charactersIn: "`")) + s = unwrapSurroundingQuotes(s) for prefix in ["$ ", "# ", "% "] where s.hasPrefix(prefix) { s = String(s.dropFirst(prefix.count)) break } - return s + return unwrapSurroundingQuotes(s) + } + + /// Unwrap one matching pair of quotes around the whole line. + static func unwrapSurroundingQuotes(_ text: String) -> String { + guard text.count >= 2, let first = text.first, let last = text.last else { return text } + if (first == "\"" && last == "\"") || (first == "'" && last == "'") { + return String(text.dropFirst().dropLast()) + } + return text } } diff --git a/Sources/LLM/Completion.swift b/Sources/LLM/Completion.swift index c8a6fef..13a83d1 100644 --- a/Sources/LLM/Completion.swift +++ b/Sources/LLM/Completion.swift @@ -64,6 +64,7 @@ enum CompletionSanitizer { line = trimTrailingWhitespace(line) line = line.trimmingCharacters(in: CharacterSet(charactersIn: "`")) + line = unwrapSurroundingQuotes(line) line = trimTrailingWhitespace(line) // Preferred contract: the model echoes the full command line, so we @@ -72,28 +73,43 @@ enum CompletionSanitizer { if line.hasPrefix(currentLine) { return String(line.dropFirst(currentLine.count)) } - // Fallback: the model returned just the suffix. - return line.trimmingCharacters(in: .whitespaces) + // Some models pad the echoed line with a leading space. + let stripped = String(line.drop(while: { $0 == " " || $0 == "\t" })) + if stripped.hasPrefix(currentLine) { + return String(stripped.dropFirst(currentLine.count)) + } + // Fallback: the model returned just the suffix. Keep a leading space so + // currentLine "git" + suffix " status" does not become "gitstatus". + return trimTrailingWhitespace(line) } private static func stripFenceLines(_ text: String) -> String { text.split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("```") } + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("```") } .joined(separator: "\n") } private static func firstNonBlankLine(_ text: String) -> String? { text.split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) - .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } private static func trimTrailingWhitespace(_ text: String) -> String { var result = text - while let last = result.last, last == " " || last == "\t" { result.removeLast() } + while let last = result.last, last.isWhitespace { result.removeLast() } return result } + + /// Unwrap one matching pair of quotes around the whole line. + static func unwrapSurroundingQuotes(_ text: String) -> String { + guard text.count >= 2, let first = text.first, let last = text.last else { return text } + if (first == "\"" && last == "\"") || (first == "'" && last == "'") { + return String(text.dropFirst().dropLast()) + } + return text + } } /// Decides whether a completion request is worth making for the current line. diff --git a/Sources/LLM/LLMProvider.swift b/Sources/LLM/LLMProvider.swift index 34b320e..9551d78 100644 --- a/Sources/LLM/LLMProvider.swift +++ b/Sources/LLM/LLMProvider.swift @@ -97,7 +97,7 @@ final class HTTPProviderClient: LLMProviderClient { } @MainActor private func streamOpenAIChat(request: LLMRequest, onDelta: (@MainActor (String) -> Void)?) async throws -> LLMResponse { - let client = OpenAI(configuration: openAIConfiguration()) + let client = OpenAI(configuration: try openAIConfiguration()) guard let systemMessage = ChatQuery.ChatCompletionMessageParam(role: .system, content: request.systemPrompt), let userMessage = ChatQuery.ChatCompletionMessageParam(role: .user, content: request.userPrompt) else { throw LLMProviderError.malformedJSON @@ -196,8 +196,8 @@ final class HTTPProviderClient: LLMProviderClient { return try finalize(content: content, reasoning: reasoning, model: model, usage: usage) } - private func openAIConfiguration() -> OpenAI.Configuration { - let (scheme, host, port, basePath) = ProviderURLSplitter.split(baseURL: profile.baseURL, endpoint: profile.chatEndpoint) + private func openAIConfiguration() throws -> OpenAI.Configuration { + let (scheme, host, port, basePath) = try ProviderURLSplitter.split(baseURL: profile.baseURL, endpoint: profile.chatEndpoint) return OpenAI.Configuration( token: apiKey, host: host, @@ -255,12 +255,22 @@ final class HTTPProviderClient: LLMProviderClient { /// Splits a base URL + endpoint into the components the MacPaw OpenAI SDK needs. /// Ported from runse so the streaming URL is built identically. Pure/testable. enum ProviderURLSplitter { - static func split(baseURL: String, endpoint: String) -> (scheme: String, host: String, port: Int, basePath: String) { - let url = URL(string: baseURL) - let scheme = url?.scheme ?? "https" - let host = url?.host ?? "api.openai.com" - let port = url?.port ?? (scheme == "https" ? 443 : 80) - let path = url?.path ?? "" + static func split(baseURL: String, endpoint: String) throws -> (scheme: String, host: String, port: Int, basePath: String) { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw LLMProviderError.invalidURL } + // Accept "host[:port][/path]" without a scheme; never guess a vendor host. + let normalized = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard let url = URL(string: normalized), + let host = url.host, + !host.isEmpty else { + throw LLMProviderError.invalidURL + } + let scheme = (url.scheme ?? "https").lowercased() + guard scheme == "https" || scheme == "http" else { + throw LLMProviderError.invalidURL + } + let port = url.port ?? (scheme == "https" ? 443 : 80) + let path = url.path // MacPaw/OpenAI tacks "/chat/completions" onto basePath itself, so we // strip that suffix if the profile's endpoint includes it. let endpointBase: String diff --git a/Sources/Model/CommandHistory.swift b/Sources/Model/CommandHistory.swift index 923ad88..f7eb4b2 100644 --- a/Sources/Model/CommandHistory.swift +++ b/Sources/Model/CommandHistory.swift @@ -6,34 +6,37 @@ import Foundation /// This is a best-effort *local* mirror of what the user types — it does not /// read the remote shell's own history. Entries are capped, deduplicated /// (most-recent first), and persisted in UserDefaults so suggestions survive -/// app restarts. Stored as plain text; callers should avoid recording lines -/// that are obviously secrets (handled at the call site). +/// app restarts. Stored as plain text; `CommandHistoryPolicy` drops obvious +/// secret-bearing lines before they are saved. final class CommandHistory { static let shared = CommandHistory() private let defaultsKey = "commandHistory" + private let defaults: UserDefaults private let maxEntries = 200 - private let minLength = 2 private var entries: [String] = [] // most-recent first - init() { load() } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } private func load() { - if let saved = UserDefaults.standard.stringArray(forKey: defaultsKey) { + if let saved = defaults.stringArray(forKey: defaultsKey) { entries = saved } } private func persist() { - UserDefaults.standard.set(entries, forKey: defaultsKey) + defaults.set(entries, forKey: defaultsKey) } - /// Record a completed command line. Blank/too-short lines are ignored; - /// duplicates move to the front so recent commands rank highest. + /// Record a completed command line. Blank/too-short/secret-looking lines + /// are ignored; duplicates move to the front so recent commands rank highest. func record(_ line: String) { + guard CommandHistoryPolicy.shouldRecord(line) else { return } let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.count >= minLength else { return } entries.removeAll { $0 == trimmed } entries.insert(trimmed, at: 0) if entries.count > maxEntries { @@ -65,3 +68,33 @@ final class CommandHistory { persist() } } + +/// Decides whether a committed line is safe to persist as shell history. +enum CommandHistoryPolicy { + static let minLength = 2 + + static func shouldRecord(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= minLength else { return false } + let lower = trimmed.lowercased() + if lower == "passwd" || lower.hasPrefix("passwd ") { return false } + if lower.hasPrefix("sshpass ") { return false } + return !containsSecretAssignment(trimmed) + } + + /// True when a whitespace-delimited token looks like `NAME=value` and NAME + /// is a password/secret/token/api-key style identifier. + private static func containsSecretAssignment(_ line: String) -> Bool { + for token in line.split(whereSeparator: { $0.isWhitespace }) { + guard let eq = token.firstIndex(of: "=") else { continue } + let name = token[.. Void) { + guard !fired else { return } + fired = true + handler(error) + } +} diff --git a/Sources/SSH/KnownHosts.swift b/Sources/SSH/KnownHosts.swift index 5afc56a..de286e5 100644 --- a/Sources/SSH/KnownHosts.swift +++ b/Sources/SSH/KnownHosts.swift @@ -32,30 +32,43 @@ struct HostKeyPrompt { enum SSHFingerprint { static func sha256(ofOpenSSH openSSH: String) -> String { let fields = openSSH.split(separator: " ", omittingEmptySubsequences: true) - guard fields.count >= 2, let blob = Data(base64Encoded: String(fields[1])) else { + guard fields.count >= 2, let blob = decodeKeyBlob(String(fields[1])) else { return "SHA256:" } let digest = SHA256.hash(data: blob) let b64 = Data(digest).base64EncodedString().replacingOccurrences(of: "=", with: "") return "SHA256:" + b64 } + + /// OpenSSH key blobs are standard base64; some encodings omit padding, which + /// `Data(base64Encoded:)` rejects unless we restore it. + static func decodeKeyBlob(_ b64: String) -> Data? { + var padded = b64.trimmingCharacters(in: .whitespacesAndNewlines) + let remainder = padded.count % 4 + if remainder != 0 { + padded += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: padded, options: .ignoreUnknownCharacters) + } } /// Persists accepted host keys (OpenSSH public-key strings) keyed by host:port, /// for trust-on-first-use verification. final class KnownHostsStore { private let defaultsKey = "knownHosts" + private let defaults: UserDefaults private var map: [String: String] - init() { - map = (UserDefaults.standard.dictionary(forKey: defaultsKey) as? [String: String]) ?? [:] + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + map = (defaults.dictionary(forKey: defaultsKey) as? [String: String]) ?? [:] } func key(for hostID: String) -> String? { map[hostID] } func remember(_ key: String, for hostID: String) { map[hostID] = key - UserDefaults.standard.set(map, forKey: defaultsKey) + defaults.set(map, forKey: defaultsKey) } } diff --git a/Sources/SSH/PTYChannelHandler.swift b/Sources/SSH/PTYChannelHandler.swift index 4e784f8..7198e44 100644 --- a/Sources/SSH/PTYChannelHandler.swift +++ b/Sources/SSH/PTYChannelHandler.swift @@ -23,6 +23,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { private let onClose: (Error?) -> Void private var context: ChannelHandlerContext? + private var closeOnce = ChannelCloseOnce() init( term: String, @@ -66,7 +67,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { } func channelInactive(context: ChannelHandlerContext) { - onClose(nil) + closeOnce.deliver(nil, to: onClose) context.fireChannelInactive() } @@ -79,7 +80,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { } func errorCaught(context: ChannelHandlerContext, error: Error) { - onClose(error) + closeOnce.deliver(error, to: onClose) context.close(promise: nil) } diff --git a/Sources/SSH/PortForwardManager.swift b/Sources/SSH/PortForwardManager.swift index 0971502..c4c15f7 100644 --- a/Sources/SSH/PortForwardManager.swift +++ b/Sources/SSH/PortForwardManager.swift @@ -57,6 +57,10 @@ final class PortForwardManager { /// off->on toggle would otherwise fail with EADDRINUSE. private var closing: [UUID: EventLoopFuture] = [:] + /// Set by `stopAll()`. An in-flight bind that completes afterwards must + /// close the new listener instead of putting it back into `listeners`. + private var tearingDown = false + private let log = Logger(subsystem: "io.github.madeye.gterm", category: "PortForward") init(parentChannel: Channel, @@ -78,6 +82,7 @@ final class PortForwardManager { func start(_ forward: PortForward) { group.next().execute { [weak self] in guard let self else { return } + if self.tearingDown { return } let id = forward.id // Idempotent: already listening, or a bind is already in flight. if self.listeners[id] != nil || self.starting.contains(id) { return } @@ -96,6 +101,10 @@ final class PortForwardManager { /// already contains the id (cleared here when the bind settles). private func performBind(_ forward: PortForward) { let id = forward.id + if tearingDown { + starting.remove(id) + return + } let bootstrap = ServerBootstrap(group: self.group) .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .serverChannelOption(ChannelOptions.backlog, value: 16) @@ -111,6 +120,12 @@ final class PortForwardManager { bootstrap.bind(host: "127.0.0.1", port: forward.localPort).whenComplete { [weak self] result in guard let self else { return } self.starting.remove(id) + if self.tearingDown { + if case .success(let serverChannel) = result { + serverChannel.close(promise: nil) + } + return + } switch result { case .success(let serverChannel): self.listeners[id] = serverChannel @@ -154,6 +169,7 @@ final class PortForwardManager { let promise = loop.makePromise(of: Void.self) loop.execute { [weak self] in guard let self else { return promise.succeed(()) } + self.tearingDown = true var closes: [EventLoopFuture] = [] for ch in self.listeners.values { closes.append(ch.close().recover { _ in () }) } self.listeners.removeAll() diff --git a/Sources/SSH/SSHKeyParser.swift b/Sources/SSH/SSHKeyParser.swift index 1a608e9..31dfb16 100644 --- a/Sources/SSH/SSHKeyParser.swift +++ b/Sources/SSH/SSHKeyParser.swift @@ -46,7 +46,7 @@ enum SSHKeyParser { throw SSHKeyError.encrypted } if trimmed.contains("BEGIN EC PRIVATE KEY") || trimmed.contains("BEGIN PRIVATE KEY") { - return try parsePEMECDSA(text) + return try parsePEMECDSA(trimmed) } if trimmed.contains("PUBLIC KEY") || trimmed.hasPrefix("ssh-") || trimmed.hasPrefix("ecdsa-") { throw SSHKeyError.notAPrivateKey @@ -54,6 +54,24 @@ enum SSHKeyParser { throw SSHKeyError.malformed("unrecognized format") } + /// Parse every non-empty key blob, skipping ones that fail. The first + /// parse error is returned so the caller can surface it when *nothing* + /// usable remains (no valid key and no password). + static func parseUsable(_ texts: [String]) -> (keys: [ParsedKey], firstError: Error?) { + var keys: [ParsedKey] = [] + var firstError: Error? + for text in texts { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + do { + keys.append(try parse(trimmed)) + } catch { + if firstError == nil { firstError = error } + } + } + return (keys, firstError) + } + private static func friendlyType(_ sshName: String) -> String { switch sshName { case "ssh-ed25519": return "Ed25519" diff --git a/Sources/SSH/SSHPort.swift b/Sources/SSH/SSHPort.swift new file mode 100644 index 0000000..5011a39 --- /dev/null +++ b/Sources/SSH/SSHPort.swift @@ -0,0 +1,10 @@ +import Foundation + +/// TCP port numbers accepted for SSH connections and similar UI fields. +enum SSHPort { + static func parse(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), (1...65535).contains(value) else { return nil } + return value + } +} diff --git a/Sources/SSH/SSHSession.swift b/Sources/SSH/SSHSession.swift index cd15fa0..0adb104 100644 --- a/Sources/SSH/SSHSession.swift +++ b/Sources/SSH/SSHSession.swift @@ -19,16 +19,6 @@ struct SSHConnection: Identifiable { var savedID: UUID? = nil } -/// High-level lifecycle of an SSH session, surfaced to the UI. -enum SSHSessionState: Equatable { - case idle - case connecting - case authenticating - case connected - case failed(String) - case closed -} - /// Drives an interactive SSH shell over a PTY using swift-nio-ssh and feeds it /// into a TerminalSurfaceView. It is the surface's delegate: user input flows /// out to the channel, server output flows into the surface, and resizes are @@ -36,7 +26,7 @@ enum SSHSessionState: Equatable { final class SSHSession: TerminalSession { private let connection: SSHConnection private weak var view: TerminalSurfaceView? - private let onStateChange: (SSHSessionState) -> Void + private let lifecycle: SessionStateNotifier private let onHostKeyPrompt: TOFUHostKeyDelegate.Prompt? private let group: EventLoopGroup @@ -61,32 +51,29 @@ final class SSHSession: TerminalSession { self.forwards = forwards self.onHostKeyPrompt = onHostKeyPrompt self.onForwardChange = onForwardChange - self.onStateChange = onStateChange + self.lifecycle = SessionStateNotifier(onStateChange: onStateChange) self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) } // MARK: TerminalSession func start() { - notify(.connecting) + lifecycle.setStopped(false) + lifecycle.notify(.connecting) // Build authentication offers in preference order: private key (if - // provided), then password (if provided). + // provided), then password (if provided). Skip keys that fail to parse + // so one RSA/encrypted key does not block a good key or password. var offers: [NIOSSHUserAuthenticationOffer.Offer] = [] - for keyText in connection.privateKeys where !keyText.isEmpty { - do { - let parsed = try SSHKeyParser.parse(keyText) - offers.append(.privateKey(.init(privateKey: parsed.key))) - } catch { - notify(.failed(Self.describe(error))) - return - } + let parsed = SSHKeyParser.parseUsable(connection.privateKeys) + for item in parsed.keys { + offers.append(.privateKey(.init(privateKey: item.key))) } if !connection.password.isEmpty { offers.append(.password(.init(password: connection.password))) } guard !offers.isEmpty else { - notify(.failed("No authentication method provided.")) + lifecycle.notify(.failed(parsed.firstError.map(Self.describe) ?? "No authentication method provided.")) return } @@ -98,6 +85,7 @@ final class SSHSession: TerminalSession { let bootstrap = ClientBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .channelOption(ChannelOptions.connectTimeout, value: .seconds(20)) .channelInitializer { channel in let sshHandler = NIOSSHHandler( role: .client(.init( @@ -110,12 +98,16 @@ final class SSHSession: TerminalSession { return channel.pipeline.addHandler(sshHandler) } - notify(.authenticating) + lifecycle.notify(.authenticating) bootstrap.connect(host: connection.host, port: connection.port).whenComplete { [weak self] result in guard let self else { return } + if self.lifecycle.isStopped() { + if case .success(let channel) = result { channel.close(promise: nil) } + return + } switch result { case .failure(let error): - self.notify(.failed(Self.describe(error))) + self.lifecycle.notify(.failed(Self.describe(error))) case .success(let channel): self.channel = channel self.openShellChannel(on: channel) @@ -124,6 +116,7 @@ final class SSHSession: TerminalSession { } func stop() { + lifecycle.setStopped(true) let group = self.group // Close port-forward listeners + tunnels FIRST and wait for them to be // fully released, THEN close the channels and shut the group down. Shutting @@ -146,13 +139,21 @@ final class SSHSession: TerminalSession { // MARK: Open the PTY shell child channel private func openShellChannel(on channel: Channel) { + if lifecycle.isStopped() { + channel.close(promise: nil) + return + } let (cols, rows) = view?.gridSize ?? (80, 24) channel.pipeline.handler(type: NIOSSHHandler.self).whenComplete { [weak self] result in guard let self else { return } + if self.lifecycle.isStopped() { + channel.close(promise: nil) + return + } switch result { case .failure(let error): - self.notify(.failed(Self.describe(error))) + self.lifecycle.notify(.failed(Self.describe(error))) case .success(let sshHandler): let promise = channel.eventLoop.makePromise(of: Channel.self) @@ -177,9 +178,13 @@ final class SSHSession: TerminalSession { promise.futureResult.whenComplete { [weak self] result in guard let self else { return } + if self.lifecycle.isStopped() { + if case .success(let child) = result { child.close(promise: nil) } + return + } switch result { case .failure(let error): - self.notify(.failed(Self.describe(error))) + self.lifecycle.notify(.failed(Self.describe(error))) case .success(let childChannel): self.childChannel = childChannel // The parent `channel` is the authenticated connection; @@ -191,7 +196,7 @@ final class SSHSession: TerminalSession { ) self.forwardManager = mgr for f in self.forwards where f.autoStart { mgr.start(f) } - self.notify(.connected) + self.lifecycle.notify(.connected) } } } @@ -221,9 +226,9 @@ final class SSHSession: TerminalSession { private func handleChannelClose(_ error: Error?) { if let error { - notify(.failed(Self.describe(error))) + lifecycle.notify(.failed(Self.describe(error))) } else { - notify(.closed) + lifecycle.notify(.closed) } } @@ -247,10 +252,6 @@ final class SSHSession: TerminalSession { // MARK: Helpers - private func notify(_ state: SSHSessionState) { - DispatchQueue.main.async { [onStateChange] in onStateChange(state) } - } - private static func describe(_ error: Error) -> String { if let keyError = error as? SSHKeyError { return keyError.description diff --git a/Sources/SSH/SessionStateNotifier.swift b/Sources/SSH/SessionStateNotifier.swift new file mode 100644 index 0000000..cd996f6 --- /dev/null +++ b/Sources/SSH/SessionStateNotifier.swift @@ -0,0 +1,47 @@ +import Foundation + +/// High-level lifecycle of an SSH session, surfaced to the UI. +enum SSHSessionState: Equatable { + case idle + case connecting + case authenticating + case connected + case failed(String) + case closed +} + +/// Delivers session state to the UI on the main queue. +/// +/// `stop()` can race a `notify` that has already decided to hop: if the +/// callback ran without re-checking, a queued `.connected` would overwrite +/// `ActiveSession.state` after disconnect, and the subsequent +/// `handleChannelClose` would be dropped because the session is stopped. +/// The hop always re-reads `isStopped` on the main queue. +final class SessionStateNotifier { + private let lock = NSLock() + private var stopped = false + private let onStateChange: (SSHSessionState) -> Void + + init(onStateChange: @escaping (SSHSessionState) -> Void) { + self.onStateChange = onStateChange + } + + func setStopped(_ value: Bool) { + lock.lock() + stopped = value + lock.unlock() + } + + func isStopped() -> Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } + + func notify(_ state: SSHSessionState) { + DispatchQueue.main.async { [weak self] in + guard let self, !self.isStopped() else { return } + self.onStateChange(state) + } + } +} diff --git a/Sources/UI/AddConnectionView.swift b/Sources/UI/AddConnectionView.swift index e663919..60ae644 100644 --- a/Sources/UI/AddConnectionView.swift +++ b/Sources/UI/AddConnectionView.swift @@ -25,7 +25,7 @@ struct AddConnectionView: View { private var isValid: Bool { !connection.host.trimmingCharacters(in: .whitespaces).isEmpty && !connection.username.trimmingCharacters(in: .whitespaces).isEmpty && - Int(portText) != nil + SSHPort.parse(portText) != nil } var body: some View { @@ -133,7 +133,7 @@ struct AddConnectionView: View { private func save() { connection.host = connection.host.trimmingCharacters(in: .whitespaces) connection.username = connection.username.trimmingCharacters(in: .whitespaces) - connection.port = Int(portText) ?? 22 + connection.port = SSHPort.parse(portText) ?? 22 store.save(connection, password: password) dismiss() } diff --git a/Tests/ChannelCloseOnceTests.swift b/Tests/ChannelCloseOnceTests.swift new file mode 100644 index 0000000..9536826 --- /dev/null +++ b/Tests/ChannelCloseOnceTests.swift @@ -0,0 +1,29 @@ +import XCTest + +private struct DummyError: Error {} + +final class ChannelCloseOnceTests: XCTestCase { + func testErrorThenInactiveKeepsTheError() { + var once = ChannelCloseOnce() + var seen: [String] = [] + once.deliver(DummyError()) { error in + seen.append(error == nil ? "nil" : "err") + } + once.deliver(nil) { error in + seen.append(error == nil ? "nil" : "err") + } + XCTAssertEqual(seen, ["err"]) + } + + func testCleanCloseThenErrorStaysClosed() { + var once = ChannelCloseOnce() + var seen: [String] = [] + once.deliver(nil) { error in + seen.append(error == nil ? "nil" : "err") + } + once.deliver(DummyError()) { error in + seen.append(error == nil ? "nil" : "err") + } + XCTAssertEqual(seen, ["nil"]) + } +} diff --git a/Tests/CommandAssistantTests.swift b/Tests/CommandAssistantTests.swift index 91583e0..af547f2 100644 --- a/Tests/CommandAssistantTests.swift +++ b/Tests/CommandAssistantTests.swift @@ -60,6 +60,23 @@ final class CommandSanitizerTests: XCTestCase { func testEmptyForBlank() { XCTAssertEqual(CommandSanitizer.command(fromOutput: " \n\n "), "") } + + func testStripsCRLF() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "tmux new-window\r\n"), "tmux new-window") + } + + func testUnwrapsSurroundingQuotes() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "\"tmux new-window\""), "tmux new-window") + XCTAssertEqual(CommandSanitizer.command(fromOutput: "'df -h'"), "df -h") + } + + func testQuotedPromptLine() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "\"$ ls -la\""), "ls -la") + } + + func testKeepsInternalQuotes() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "echo \"hello\""), "echo \"hello\"") + } } final class QuickCommandsTests: XCTestCase { diff --git a/Tests/CommandHistoryTests.swift b/Tests/CommandHistoryTests.swift new file mode 100644 index 0000000..9f0469e --- /dev/null +++ b/Tests/CommandHistoryTests.swift @@ -0,0 +1,66 @@ +import XCTest + +final class CommandHistoryPolicyTests: XCTestCase { + func testAcceptsOrdinaryCommands() { + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("ls -la")) + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("git status")) + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("cd /tmp")) + } + + func testRejectsBlankAndShort() { + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord(" ")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("x")) + } + + func testRejectsPasswdAndSshpass() { + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("passwd")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("passwd alice")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("sshpass -p secret ssh host")) + } + + func testRejectsSecretAssignments() { + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("export AWS_SECRET_ACCESS_KEY=abc")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("PASSWORD=hunter2")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("MY_API_KEY=xyz")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("token=abcd")) + } + + func testKeepsAssignmentsThatAreNotSecrets() { + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("FOO=bar make test")) + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("echo password is not an assignment")) + } +} + +final class CommandHistoryTests: XCTestCase { + private func makeHistory() -> (CommandHistory, String, UserDefaults) { + let suite = "gterm.tests.commandHistory.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return (CommandHistory(defaults: defaults), suite, defaults) + } + + func testRecordSkipsSecretsAndStoresCommands() { + let (history, suite, defaults) = makeHistory() + defer { defaults.removePersistentDomain(forName: suite) } + + history.record("ls -la") + history.record("passwd") + history.record("export TOKEN=shh") + history.record("git status") + + XCTAssertEqual(history.recent(), ["git status", "ls -la"]) + XCTAssertEqual(history.suggestions(prefix: "git"), ["git status"]) + XCTAssertEqual(history.suggestions(prefix: "pass"), []) + } + + func testDedupMovesToFront() { + let (history, suite, defaults) = makeHistory() + defer { defaults.removePersistentDomain(forName: suite) } + + history.record("ls") + history.record("cd /tmp") + history.record("ls") + XCTAssertEqual(history.recent(), ["ls", "cd /tmp"]) + } +} diff --git a/Tests/CompletionTests.swift b/Tests/CompletionTests.swift index 5e382d8..b266520 100644 --- a/Tests/CompletionTests.swift +++ b/Tests/CompletionTests.swift @@ -73,6 +73,24 @@ final class CompletionSanitizerTests: XCTestCase { // currentLine "git" + full "git status" -> suffix " status" (with space). XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "git status", currentLine: "git"), " status") } + + func testFallbackSuffixKeepsLeadingSpace() { + // Model returned only the suffix, including the separator space. + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: " status", currentLine: "git"), " status") + } + + func testStripsCRLFBeforeMatchingPrefix() { + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "git status\r\n", currentLine: "git st"), "atus") + } + + func testUnwrapsSurroundingQuotes() { + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "\"git status\"", currentLine: "git st"), "atus") + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "'git status'", currentLine: "git st"), "atus") + } + + func testPaddedFullLineStillYieldsSuffix() { + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: " git status", currentLine: "git st"), "atus") + } } final class CompletionPolicyTests: XCTestCase { diff --git a/Tests/KnownHostsTests.swift b/Tests/KnownHostsTests.swift new file mode 100644 index 0000000..9546e68 --- /dev/null +++ b/Tests/KnownHostsTests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class SSHFingerprintTests: XCTestCase { + /// Public half of the throwaway Ed25519 fixture in SSHKeyParserTests. + private let ed25519Pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFkeTUyaPwI3q+HM6qK0s/wnVByl92lb9tCI1TFXG1sT gterm-test" + + /// `ssh-keygen -l -E sha256` of that key. + private let ed25519Fingerprint = "SHA256:U0RqZRgE0EEoGl1k8PBOeB9pKKmxdKJsZp/ymIsMdZU" + + private let ecdsaPub = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOofz14uFVl80GLndS/x5F7R+3HcQBC5SIqH31Ns4OT8Eu/z3Xb6n1XiHrTEwq9c6TuEPD2KqhcrgT1rj37hYxg= gterm-ecdsa" + private let ecdsaFingerprint = "SHA256:Or70brQY3Wn6ZyBb6B++Z5hKXLGGwwxWnyS68moCQcE" + + func testFingerprintMatchesOpenSSHAndUnpaddedBlob() { + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: ed25519Pub), ed25519Fingerprint) + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: ecdsaPub), ecdsaFingerprint) + + // ECDSA blobs include `=` padding. Stripping it used to make + // `Data(base64Encoded:)` fail and report SHA256:. + let fields = ecdsaPub.split(separator: " ") + XCTAssertTrue(fields[1].contains("="), "fixture must include padding so the unpadded path is exercised") + let unpadded = "\(fields[0]) \(String(fields[1]).replacingOccurrences(of: "=", with: "")) \(fields[2])" + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: unpadded), ecdsaFingerprint) + } + + func testUnknownWhenBlobMissing() { + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: "ssh-ed25519"), "SHA256:") + } +} + +final class KnownHostsStoreTests: XCTestCase { + func testRememberAndLookup() { + let suite = "gterm.tests.knownHosts.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("could not create suite") + } + defaults.removePersistentDomain(forName: suite) + let store = KnownHostsStore(defaults: defaults) + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFkeTUyaPwI3q+HM6qK0s/wnVByl92lb9tCI1TFXG1sT" + store.remember(key, for: "example.com:22") + XCTAssertEqual(store.key(for: "example.com:22"), key) + XCTAssertNil(store.key(for: "other:22")) + + let reloaded = KnownHostsStore(defaults: defaults) + XCTAssertEqual(reloaded.key(for: "example.com:22"), key) + defaults.removePersistentDomain(forName: suite) + } +} diff --git a/Tests/ProviderRequestTests.swift b/Tests/ProviderRequestTests.swift index 6e5aa17..125f8d4 100644 --- a/Tests/ProviderRequestTests.swift +++ b/Tests/ProviderRequestTests.swift @@ -35,8 +35,8 @@ final class ProviderRequestTests: XCTestCase { /// Covers the streaming URL construction the OpenAI SDK consumes (the runse /// `ProviderURLSplitter`). final class ProviderURLSplitterTests: XCTestCase { - func testSplitsStandardOpenAIBase() { - let (scheme, host, port, basePath) = ProviderURLSplitter.split( + func testSplitsStandardOpenAIBase() throws { + let (scheme, host, port, basePath) = try ProviderURLSplitter.split( baseURL: "https://api.openai.com", endpoint: "/v1/chat/completions" ) XCTAssertEqual(scheme, "https") @@ -46,23 +46,44 @@ final class ProviderURLSplitterTests: XCTestCase { XCTAssertEqual(basePath, "/v1") } - func testSplitsChinaProviderWithPathPrefix() { + func testSplitsChinaProviderWithPathPrefix() throws { // GLM keeps its "/api/paas/v4" prefix once "/chat/completions" is stripped. - let (_, host, _, basePath) = ProviderURLSplitter.split( + let (_, host, _, basePath) = try ProviderURLSplitter.split( baseURL: "https://open.bigmodel.cn/api/paas/v4", endpoint: "/chat/completions" ) XCTAssertEqual(host, "open.bigmodel.cn") XCTAssertEqual(basePath, "/api/paas/v4") } - func testSplitsDeepSeekBareBase() { - let (_, host, _, basePath) = ProviderURLSplitter.split( + func testSplitsDeepSeekBareBase() throws { + let (_, host, _, basePath) = try ProviderURLSplitter.split( baseURL: "https://api.deepseek.com", endpoint: "/chat/completions" ) XCTAssertEqual(host, "api.deepseek.com") XCTAssertEqual(basePath, "") } + func testHostWithoutSchemeDefaultsToHTTPS() throws { + let (scheme, host, port, _) = try ProviderURLSplitter.split( + baseURL: "api.example.com", endpoint: "/v1/chat/completions" + ) + XCTAssertEqual(scheme, "https") + XCTAssertEqual(host, "api.example.com") + XCTAssertEqual(port, 443) + } + + func testInvalidURLDoesNotFallBackToOpenAI() { + let bad = ["", "https://", " ", "not a url", "ftp://api.example.com"] + for base in bad { + XCTAssertThrowsError( + try ProviderURLSplitter.split(baseURL: base, endpoint: "/v1/chat/completions"), + "expected invalidURL for \(base)" + ) { error in + XCTAssertEqual(error as? LLMProviderError, .invalidURL) + } + } + } + func testExtraHeadersParsing() { XCTAssertEqual(ProviderURLSplitter.extraHeaders(#"{"X-Test":"yes"}"#), ["X-Test": "yes"]) XCTAssertEqual(ProviderURLSplitter.extraHeaders("not json"), [:]) diff --git a/Tests/SSHKeyParserTests.swift b/Tests/SSHKeyParserTests.swift new file mode 100644 index 0000000..5f0a319 --- /dev/null +++ b/Tests/SSHKeyParserTests.swift @@ -0,0 +1,118 @@ +import XCTest +import Crypto + +final class SSHKeyParserTests: XCTestCase { + /// Throwaway Ed25519 OpenSSH key generated for this test; never used for auth. + private let ed25519OpenSSH = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW + QyNTUxOQAAACBZHk1Mmj8CN6vhzOqitLP8J1QcpfdpW/bQiNUxVxtbEwAAAJD5i5Nx+YuT + cQAAAAtzc2gtZWQyNTUxOQAAACBZHk1Mmj8CN6vhzOqitLP8J1QcpfdpW/bQiNUxVxtbEw + AAAECIjXJdhOXvBQSWN+Sqwz/BlSf7I/TX+18zUiqBe/s2TlkeTUyaPwI3q+HM6qK0s/wn + VByl92lb9tCI1TFXG1sTAAAACmd0ZXJtLXRlc3QBAgM= + -----END OPENSSH PRIVATE KEY----- + """ + + /// Same algorithm, passphrase-protected (cipher is aes256-ctr, not "none"). + private let ed25519Encrypted = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCrIkFwQm + hXWHjae1Qp+2p2AAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIMyWguNQ0g3osSDR + XQU2rjiEjlt6AXm90mkkBMvxgMhXAAAAkPTlcygrVo7VOKt7LDwfo9oglQYRTWcslEhn/2 + C7h+6ZU5X3VaD5214RAf+xO/gfsdcdhY4+gTey1nhgB+gtjmpyJC1FC7DNtiJxECr7gIQq + DINVSBE90wpthbbRU0R4jLmb9FjdzJPQ6tiq6mElm3/tp7wh7FJsDjRPDzcxqim9b2yGEO + /cFjQcYTsjymgDOg== + -----END OPENSSH PRIVATE KEY----- + """ + + private let ecdsaP256OpenSSH = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS + 1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTqH89eLhVZfNBi53Uv8eRe0ftx3EAQ + uUiKh99TbODk/BLv8912+p9V4h60xMKvXOk7hDw9iqoXK4E9a49+4WMYAAAAqHCR8Ahwkf + AIAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOofz14uFVl80GLn + dS/x5F7R+3HcQBC5SIqH31Ns4OT8Eu/z3Xb6n1XiHrTEwq9c6TuEPD2KqhcrgT1rj37hYx + gAAAAhAMp+NnVZlln1HtBxI3pQJu3vgCvnNJrJk4F3E6EYKytPAAAAC2d0ZXJtLWVjZHNh + AQIDBA== + -----END OPENSSH PRIVATE KEY----- + """ + + func testParsesEd25519OpenSSH() throws { + let parsed = try SSHKeyParser.parse(ed25519OpenSSH) + XCTAssertEqual(parsed.type, "Ed25519") + } + + func testParsesECDSAP256OpenSSH() throws { + let parsed = try SSHKeyParser.parse(ecdsaP256OpenSSH) + XCTAssertEqual(parsed.type, "ECDSA P-256") + } + + func testEncryptedOpenSSHThrows() { + XCTAssertThrowsError(try SSHKeyParser.parse(ed25519Encrypted)) { error in + XCTAssertEqual(error as? SSHKeyError, .encrypted) + } + } + + func testRSAThrowsUnsupported() { + let rsa = "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----" + XCTAssertThrowsError(try SSHKeyParser.parse(rsa)) { error in + XCTAssertEqual(error as? SSHKeyError, .unsupportedType("ssh-rsa")) + } + } + + func testPublicKeyThrows() { + XCTAssertThrowsError(try SSHKeyParser.parse("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFkeTUyaPwI3q+HM6qK0s/wnVByl92lb9tCI1TFXG1sT comment")) { error in + XCTAssertEqual(error as? SSHKeyError, .notAPrivateKey) + } + } + + func testEmptyThrows() { + XCTAssertThrowsError(try SSHKeyParser.parse(" \n ")) { error in + XCTAssertEqual(error as? SSHKeyError, .malformed("empty")) + } + } + + func testPEMECDSAAllowsSurroundingWhitespace() throws { + let key = P256.Signing.PrivateKey() + let pem = "\n\n" + key.pemRepresentation + "\n\n" + let parsed = try SSHKeyParser.parse(pem) + XCTAssertEqual(parsed.type, "ECDSA P-256") + } + + func testParseUsableSkipsBadKeysButKeepsGood() throws { + let texts = [ + "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", + ed25519OpenSSH, + "", + "not a key", + ] + let result = SSHKeyParser.parseUsable(texts) + XCTAssertEqual(result.keys.count, 1) + XCTAssertEqual(result.keys[0].type, "Ed25519") + XCTAssertNotNil(result.firstError) + } + + func testParseUsableAllBadReportsFirstError() { + let result = SSHKeyParser.parseUsable([ + "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", + "ssh-ed25519 AAAA public", + ]) + XCTAssertTrue(result.keys.isEmpty) + XCTAssertEqual(result.firstError as? SSHKeyError, .unsupportedType("ssh-rsa")) + } +} + +extension SSHKeyError: Equatable { + static func == (lhs: SSHKeyError, rhs: SSHKeyError) -> Bool { + switch (lhs, rhs) { + case (.encrypted, .encrypted), (.notAPrivateKey, .notAPrivateKey): + return true + case (.unsupportedType(let a), .unsupportedType(let b)): + return a == b + case (.malformed(let a), .malformed(let b)): + return a == b + default: + return false + } + } +} diff --git a/Tests/SSHPortTests.swift b/Tests/SSHPortTests.swift new file mode 100644 index 0000000..1e70204 --- /dev/null +++ b/Tests/SSHPortTests.swift @@ -0,0 +1,20 @@ +import XCTest + +final class SSHPortTests: XCTestCase { + func testAcceptsTypicalPorts() { + XCTAssertEqual(SSHPort.parse("22"), 22) + XCTAssertEqual(SSHPort.parse("2222"), 2222) + XCTAssertEqual(SSHPort.parse("65535"), 65535) + XCTAssertEqual(SSHPort.parse(" 443 "), 443) + } + + func testRejectsOutOfRangeAndJunk() { + XCTAssertNil(SSHPort.parse("0")) + XCTAssertNil(SSHPort.parse("-1")) + XCTAssertNil(SSHPort.parse("65536")) + XCTAssertNil(SSHPort.parse("999999")) + XCTAssertNil(SSHPort.parse("")) + XCTAssertNil(SSHPort.parse("ssh")) + XCTAssertNil(SSHPort.parse("22a")) + } +} diff --git a/Tests/SessionStateNotifierTests.swift b/Tests/SessionStateNotifierTests.swift new file mode 100644 index 0000000..86aa9c2 --- /dev/null +++ b/Tests/SessionStateNotifierTests.swift @@ -0,0 +1,65 @@ +import XCTest + +final class SessionStateNotifierTests: XCTestCase { + func testNotifyDeliversWhenNotStopped() { + let exp = expectation(description: "delivered") + var seen: [SSHSessionState] = [] + let notifier = SessionStateNotifier { state in + seen.append(state) + exp.fulfill() + } + notifier.notify(.connecting) + wait(for: [exp], timeout: 1) + XCTAssertEqual(seen, [.connecting]) + } + + /// The skeptic race: `notify(.connected)` hops to main without the stopped + /// flag set yet; `stop()` then sets it. The main callback must drop the + /// update so the UI cannot stick on `.connected`. + func testLateConnectedDroppedAfterStop() { + let delivered = expectation(description: "must not deliver") + delivered.isInverted = true + let notifier = SessionStateNotifier { _ in + delivered.fulfill() + } + notifier.notify(.connected) + notifier.setStopped(true) + wait(for: [delivered], timeout: 0.3) + } + + func testNotifyAfterStopIsDropped() { + let delivered = expectation(description: "must not deliver") + delivered.isInverted = true + let notifier = SessionStateNotifier { _ in + delivered.fulfill() + } + notifier.setStopped(true) + notifier.notify(.failed("late")) + notifier.notify(.closed) + wait(for: [delivered], timeout: 0.3) + } + + func testFailedDoesNotOverwriteAfterStop() { + let connecting = expectation(description: "connecting") + let extra = expectation(description: "no extra after stop") + extra.expectedFulfillmentCount = 2 + extra.isInverted = true + var seen: [SSHSessionState] = [] + let notifier = SessionStateNotifier { state in + seen.append(state) + if state == .connecting { + connecting.fulfill() + } else { + extra.fulfill() + } + } + notifier.notify(.connecting) + wait(for: [connecting], timeout: 1) + XCTAssertEqual(seen, [.connecting]) + notifier.setStopped(true) + notifier.notify(.connected) + notifier.notify(.failed("channel closed")) + wait(for: [extra], timeout: 0.3) + XCTAssertEqual(seen, [.connecting]) + } +} diff --git a/project.yml b/project.yml index 08a468c..7d9ba3b 100644 --- a/project.yml +++ b/project.yml @@ -116,9 +116,20 @@ targets: sources: - path: Tests - path: Sources/LLM + # Foundation-safe units covered by the audit (no GhosttyKit / UIKit). + - path: Sources/SSH/SSHKeyParser.swift + - path: Sources/SSH/KnownHosts.swift + - path: Sources/SSH/ChannelCloseOnce.swift + - path: Sources/SSH/SessionStateNotifier.swift + - path: Sources/SSH/SSHPort.swift + - path: Sources/Model/CommandHistory.swift dependencies: - package: OpenAI - package: SwiftAnthropic + - package: SwiftNIOSSH + product: NIOSSH + - package: SwiftCrypto + product: Crypto settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.github.madeye.gtermTests