Files
Termind/TFM/Application/QuickConnectParser.swift

383 lines
16 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
nonisolated struct QuickConnectInput: Equatable, Sendable {
struct Endpoint: Equatable, Sendable {
var host: String
var username: String?
var port: Int?
}
var destination: Endpoint
var hostname: String?
/// nil inherits the saved route; an empty array explicitly clears it.
var jumps: [Endpoint]?
var isCommand: Bool
var connectionProtocol: HostTerminalProtocol = .ssh
}
nonisolated enum QuickConnectParseResult: Equatable, Sendable {
case search
case incomplete(String)
case connection(QuickConnectInput)
case invalid(String)
}
/// A small SSH argument parser, never a shell evaluator or an OpenSSH launcher.
nonisolated enum QuickConnectParser {
static func isCommand(_ input: String) -> Bool {
let first = input.split(whereSeparator: \.isWhitespace).first
return first == "ssh" || first == "/usr/bin/ssh"
|| first == "telnet" || first == "/usr/bin/telnet"
}
static func parse(_ input: String, locale: Locale = .current) -> QuickConnectParseResult {
let first = input.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isWhitespace).first
if first == "telnet" || first == "/usr/bin/telnet" {
return TelnetInputParser(locale: locale).parse(input)
}
return SSHInputParser(locale: locale).parse(input)
}
}
private nonisolated struct TelnetInputParser {
let locale: Locale
private func message(_ key: LocalizedStringResource) -> String {
AppLocalization.resolve(key, locale: locale)
}
func parse(_ input: String) -> QuickConnectParseResult {
do {
let tokens = try tokenize(input.trimmingCharacters(in: .whitespacesAndNewlines))
guard !tokens.isEmpty else { return .search }
var index = 1
var username: String?
if index < tokens.count, tokens[index].hasPrefix("-l") {
let option = tokens[index]
index += 1
if option == "-l" {
guard index < tokens.count else {
return .incomplete(message(AppStrings.Connections.enterAValueForL))
}
username = tokens[index]
index += 1
} else {
username = String(option.dropFirst(2))
}
guard username?.isEmpty == false else {
return .invalid(message(AppStrings.Connections.invalidTelnetDestination))
}
} else if index < tokens.count, tokens[index].hasPrefix("-") {
return .invalid(message(AppStrings.Connections.unsupportedTelnetOption(tokens[index])))
}
guard index < tokens.count else {
return .incomplete(message(AppStrings.Connections.enterATelnetDestination))
}
var host = tokens[index]
index += 1
if host.hasPrefix("["), host.hasSuffix("]") { host = String(host.dropFirst().dropLast()) }
guard validHost(host) else { return .invalid(message(AppStrings.Connections.invalidTelnetDestination)) }
var port: Int?
if index < tokens.count {
let value = tokens[index]
index += 1
guard value.allSatisfy({ $0.isASCII && $0.isNumber }),
let parsed = Int(value), (1...65_535).contains(parsed)
else { return .invalid(message(AppStrings.Connections.invalidTelnetPort)) }
port = parsed
}
guard index == tokens.count else {
return .invalid(message(AppStrings.Connections.remoteCommandsAreNotSupportedHere))
}
return .connection(
QuickConnectInput(
destination: .init(host: host, username: username, port: port),
isCommand: true, connectionProtocol: .telnet))
} catch let failure as Failure {
switch failure {
case .incomplete(let value): return .incomplete(value)
case .invalid(let value): return .invalid(value)
}
} catch {
return .invalid(error.localizedDescription)
}
}
private enum Failure: Error {
case incomplete(String)
case invalid(String)
}
private func tokenize(_ text: String) throws -> [String] {
var tokens: [String] = []
var token = ""
var started = false
var quote: Character?
var escaping = false
for character in text {
if escaping {
token.append(character)
escaping = false
} else if character == "\\", quote != "'" {
escaping = true
started = true
} else if let current = quote {
if character == current { quote = nil } else { token.append(character) }
} else if character == "'" || character == "\"" {
quote = character
started = true
} else if character.isWhitespace {
if started { tokens.append(token) }
token = ""
started = false
} else if ";|&<>`$".contains(character) {
throw Failure.invalid(message(AppStrings.Connections.shellExpressionsAreNotSupportedHere))
} else {
token.append(character)
started = true
}
}
guard quote == nil, !escaping else {
throw Failure.incomplete(message(AppStrings.Connections.finishTheQuotedOrEscapedArgument))
}
if started { tokens.append(token) }
return tokens
}
private func validHost(_ value: String) -> Bool {
!value.isEmpty && !value.hasPrefix("-")
&& value.contains(where: { $0.isLetter || $0.isNumber })
&& !value.contains(where: { $0.isNewline || "/@[];|&<>`$\\".contains($0) })
}
}
private nonisolated struct SSHInputParser {
let locale: Locale
private func message(_ key: LocalizedStringResource) -> String {
AppLocalization.resolve(key, locale: locale)
}
func parse(_ input: String) -> QuickConnectParseResult {
let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return .search }
let command = QuickConnectParser.isCommand(text)
do {
if !command {
guard !text.contains(where: \.isWhitespace) else { return .search }
guard let destination = try? endpoint(text) else { return .search }
return .connection(QuickConnectInput(destination: destination, isCommand: false))
}
let tokens = try tokenize(text)
var index = 1
var destination: QuickConnectInput.Endpoint?
var username: String?
var port: Int?
var hostname: String?
var jumps: [QuickConnectInput.Endpoint]?
var optionsEnded = false
while index < tokens.count {
let token = tokens[index]
index += 1
if token == "--", !optionsEnded {
optionsEnded = true
continue
}
if token.hasPrefix("-"), !optionsEnded {
guard token.count >= 2 else { throw Failure.invalid(unsupported(token)) }
let option = token[token.index(after: token.startIndex)]
guard "plJo".contains(option) else { throw Failure.invalid(unsupported(token)) }
let attached = String(token.dropFirst(2))
let value: String
if !attached.isEmpty {
value = attached
} else {
guard index < tokens.count else {
throw Failure.incomplete(message(AppStrings.Connections.enterAValueFor(token)))
}
value = tokens[index]
index += 1
}
switch option {
case "p":
let parsed = try parsePort(value)
if port == nil { port = parsed }
case "l":
try validateUsername(value)
if username == nil { username = value }
case "J":
guard jumps == nil else {
throw Failure.invalid(
message(AppStrings.Connections.useOneJOptionWithCommaSeparatedJumpHosts))
}
jumps = try parseJumps(value)
default:
let pair = value.split(maxSplits: 1, whereSeparator: { $0 == "=" || $0.isWhitespace })
guard let key = pair.first else { throw Failure.invalid(unsupported("-o")) }
let name = key.lowercased()
guard ["hostname", "user", "port", "proxyjump"].contains(name) else {
throw Failure.invalid(unsupported("-o \(key)"))
}
guard pair.count == 2 else {
throw Failure.incomplete(message(AppStrings.Connections.enterAValueFor(String(key))))
}
let argument = pair[1].trimmingCharacters(in: .whitespaces)
guard !argument.isEmpty else {
throw Failure.incomplete(message(AppStrings.Connections.enterAValueFor(String(key))))
}
switch name {
case "hostname":
try validateHost(argument)
if hostname == nil { hostname = argument }
case "user":
try validateUsername(argument)
if username == nil { username = argument }
case "port":
let parsed = try parsePort(argument)
if port == nil { port = parsed }
default:
let parsed = try parseJumps(argument)
if jumps == nil { jumps = parsed }
}
}
} else {
guard destination == nil else {
throw Failure.invalid(message(AppStrings.Connections.remoteCommandsAreNotSupportedHere))
}
let parsed = try endpoint(token)
destination = parsed
if username == nil { username = parsed.username }
}
}
guard var destination else {
throw Failure.incomplete(message(AppStrings.Connections.enterAnSshDestination))
}
destination.username = username
destination.port = port ?? destination.port
return .connection(
QuickConnectInput(destination: destination, hostname: hostname, jumps: jumps, isCommand: true)
)
} catch Failure.incomplete(let message) {
return .incomplete(message)
} catch Failure.invalid(let message) {
return .invalid(message)
} catch {
return .invalid(error.localizedDescription)
}
}
private enum Failure: Error {
case incomplete(String)
case invalid(String)
}
private func unsupported(_ option: String) -> String {
message(AppStrings.Connections.unsupportedSshOption(option))
}
private func tokenize(_ text: String) throws -> [String] {
var tokens: [String] = []
var token = ""
var started = false
var quote: Character?
var escaping = false
for character in text {
if escaping {
if quote == "\"", character != "\"", character != "\\", character != "$", character != "`" {
token.append("\\")
}
token.append(character)
escaping = false
} else if character == "\\", quote != "'" {
escaping = true
started = true
} else if let current = quote {
if character == current { quote = nil } else { token.append(character) }
} else if character == "'" || character == "\"" {
quote = character
started = true
} else if character.isWhitespace {
if started { tokens.append(token) }
token = ""
started = false
} else if ";|&<>`$".contains(character) {
throw Failure.invalid(message(AppStrings.Connections.shellExpressionsAreNotSupportedHere))
} else {
token.append(character)
started = true
}
}
guard quote == nil, !escaping else {
throw Failure.incomplete(message(AppStrings.Connections.finishTheQuotedOrEscapedArgument))
}
if started { tokens.append(token) }
return tokens
}
private func endpoint(_ text: String) throws -> QuickConnectInput.Endpoint {
var remainder = text
var username: String?
if let at = remainder.lastIndex(of: "@") {
username = String(remainder[..<at])
if let username { try validateUsername(username) }
remainder = String(remainder[remainder.index(after: at)...])
}
var port: Int?
if remainder.hasPrefix("[") {
guard let end = remainder.firstIndex(of: "]") else {
throw Failure.incomplete(message(AppStrings.Connections.finishTheBracketedIPv6Address))
}
let suffix = remainder[remainder.index(after: end)...]
if !suffix.isEmpty {
guard suffix.hasPrefix(":") else { throw invalidDestination() }
port = try parsePort(String(suffix.dropFirst()))
}
remainder = String(remainder[remainder.index(after: remainder.startIndex)..<end])
} else if remainder.filter({ $0 == ":" }).count == 1, let colon = remainder.firstIndex(of: ":") {
port = try parsePort(String(remainder[remainder.index(after: colon)...]))
remainder = String(remainder[..<colon])
}
try validateHost(remainder)
return QuickConnectInput.Endpoint(host: remainder, username: username, port: port)
}
private func parsePort(_ value: String) throws -> Int {
guard !value.isEmpty, value.allSatisfy({ $0.isASCII && $0.isNumber }),
let port = Int(value), (1...65_535).contains(port)
else { throw Failure.invalid(message(AppStrings.Connections.invalidSshPort)) }
return port
}
private func validateUsername(_ value: String) throws {
guard !value.isEmpty, !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
!value.contains(where: { $0.isWhitespace || ";|&<>`$".contains($0) })
else {
throw invalidDestination()
}
}
private func validateHost(_ value: String) throws {
guard !value.isEmpty, !value.hasPrefix("-"),
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
value.contains(where: { $0.isLetter || $0.isNumber }),
!value.contains(where: { $0.isNewline || "/@[];|&<>`$\\".contains($0) })
else { throw invalidDestination() }
}
private func parseJumps(_ value: String) throws -> [QuickConnectInput.Endpoint] {
if value == "none" { return [] }
return try value.split(separator: ",", omittingEmptySubsequences: false).map { try endpoint(String($0)) }
}
private func invalidDestination() -> Failure {
.invalid(message(AppStrings.Connections.invalidSshDestination))
}
}