230 lines
9.6 KiB
Swift
230 lines
9.6 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
public nonisolated enum RemoteConnectionOrigin: Equatable, Sendable {
|
|
case library
|
|
case externalLink(hostKeyConstraints: [Host.ID: SSHHostKeyConstraint])
|
|
|
|
public var allowsPersistence: Bool {
|
|
if case .library = self { true } else { false }
|
|
}
|
|
|
|
public var hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] {
|
|
if case .externalLink(let constraints) = self { constraints } else { [:] }
|
|
}
|
|
}
|
|
|
|
public nonisolated struct RemoteConnectionURL: Equatable, Sendable {
|
|
public enum Kind: String, Equatable, Sendable {
|
|
case ssh
|
|
case sftp
|
|
case telnet
|
|
|
|
public var defaultPort: Int {
|
|
switch self {
|
|
case .ssh, .sftp: 22
|
|
case .telnet: 23
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum SFTPType: String, Equatable, Sendable {
|
|
case binary = "i"
|
|
case directory = "d"
|
|
}
|
|
|
|
public var kind: Kind
|
|
public var username: String?
|
|
/// RFC 4248 credentials are advisory. SSH and SFTP URL passwords are rejected.
|
|
public var advisoryPassword: String?
|
|
public var host: String
|
|
public var port: Int
|
|
public var path: String?
|
|
public var sftpType: SFTPType?
|
|
public var hostKeyFingerprint: SSHHostKeyConstraint?
|
|
|
|
public init(
|
|
kind: Kind, username: String?, advisoryPassword: String?, host: String, port: Int,
|
|
path: String?, sftpType: SFTPType?, hostKeyFingerprint: SSHHostKeyConstraint?
|
|
) {
|
|
self.kind = kind
|
|
self.username = username
|
|
self.advisoryPassword = advisoryPassword
|
|
self.host = host
|
|
self.port = port
|
|
self.path = path
|
|
self.sftpType = sftpType
|
|
self.hostKeyFingerprint = hostKeyFingerprint
|
|
}
|
|
}
|
|
|
|
public nonisolated enum RemoteConnectionURLParseError: LocalizedError, Equatable, Sendable {
|
|
case unsupportedScheme
|
|
case invalidURL
|
|
case invalidHost
|
|
case invalidPort
|
|
case invalidCredentials
|
|
case invalidFingerprint
|
|
case unsupportedSFTPTextMode
|
|
case unsupportedParameter
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .unsupportedScheme: String(localized: "This connection link uses an unsupported protocol.")
|
|
case .invalidURL: String(localized: "This connection link is invalid.")
|
|
case .invalidHost: String(localized: "This connection link does not contain a valid host.")
|
|
case .invalidPort: String(localized: "This connection link does not contain a valid port.")
|
|
case .invalidCredentials:
|
|
String(localized: "SSH and SFTP connection links cannot contain passwords.")
|
|
case .invalidFingerprint: String(localized: "This connection link contains an invalid host-key fingerprint.")
|
|
case .unsupportedSFTPTextMode:
|
|
String(localized: "SFTP text transfer mode is not supported.")
|
|
case .unsupportedParameter:
|
|
String(localized: "This connection link contains an unsupported standard parameter.")
|
|
}
|
|
}
|
|
}
|
|
|
|
public nonisolated enum RemoteConnectionURLParser {
|
|
public static func parse(_ url: URL) throws -> RemoteConnectionURL {
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let rawScheme = components.scheme,
|
|
let kind = RemoteConnectionURL.Kind(rawValue: rawScheme.lowercased())
|
|
else { throw RemoteConnectionURLParseError.unsupportedScheme }
|
|
guard components.query == nil, components.fragment == nil else {
|
|
throw RemoteConnectionURLParseError.invalidURL
|
|
}
|
|
guard var host = components.host, !host.isEmpty else {
|
|
throw RemoteConnectionURLParseError.invalidHost
|
|
}
|
|
if host.hasPrefix("["), host.hasSuffix("]") { host = String(host.dropFirst().dropLast()) }
|
|
guard !host.contains(where: { $0.isWhitespace || $0.isNewline || $0 == "\0" }) else {
|
|
throw RemoteConnectionURLParseError.invalidHost
|
|
}
|
|
let port = components.port ?? kind.defaultPort
|
|
guard (1...65_535).contains(port) else { throw RemoteConnectionURLParseError.invalidPort }
|
|
|
|
let credentials = try parseCredentials(components, kind: kind)
|
|
let resource = try parseResource(components.percentEncodedPath, kind: kind)
|
|
return RemoteConnectionURL(
|
|
kind: kind,
|
|
username: credentials.username,
|
|
advisoryPassword: credentials.password,
|
|
host: host,
|
|
port: port,
|
|
path: resource.path,
|
|
sftpType: resource.type,
|
|
hostKeyFingerprint: credentials.fingerprint
|
|
)
|
|
}
|
|
|
|
private static func parseCredentials(
|
|
_ components: URLComponents, kind: RemoteConnectionURL.Kind
|
|
) throws -> (username: String?, password: String?, fingerprint: SSHHostKeyConstraint?) {
|
|
let password = try decode(components.percentEncodedPassword)
|
|
if kind != .telnet, password != nil { throw RemoteConnectionURLParseError.invalidCredentials }
|
|
|
|
guard kind != .telnet else {
|
|
let username = try nonempty(decode(components.percentEncodedUser))
|
|
return (username, try nonempty(password), nil)
|
|
}
|
|
|
|
let pieces = (components.percentEncodedUser ?? "").split(
|
|
separator: ";", maxSplits: 1, omittingEmptySubsequences: false)
|
|
let username = try nonempty(decode(String(pieces.first ?? "")))
|
|
guard pieces.count == 2 else { return (username, nil, nil) }
|
|
|
|
var fingerprint: SSHHostKeyConstraint?
|
|
for parameter in pieces[1].split(separator: ",", omittingEmptySubsequences: false) {
|
|
let pair = parameter.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard pair.count == 2, !pair[0].isEmpty else {
|
|
throw RemoteConnectionURLParseError.invalidURL
|
|
}
|
|
guard pair[0] == "fingerprint" else { continue }
|
|
guard fingerprint == nil else { throw RemoteConnectionURLParseError.invalidFingerprint }
|
|
fingerprint = try parseFingerprint(String(pair[1]))
|
|
}
|
|
return (username, nil, fingerprint)
|
|
}
|
|
|
|
private static func parseResource(
|
|
_ encodedPath: String, kind: RemoteConnectionURL.Kind
|
|
) throws -> (path: String?, type: RemoteConnectionURL.SFTPType?) {
|
|
switch kind {
|
|
case .ssh:
|
|
return (nil, nil)
|
|
case .telnet:
|
|
guard encodedPath.isEmpty || encodedPath == "/" else {
|
|
throw RemoteConnectionURLParseError.invalidURL
|
|
}
|
|
return (nil, nil)
|
|
case .sftp:
|
|
let pathAndParameters = encodedPath.split(
|
|
separator: ";", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard let decodedPath = try decode(String(pathAndParameters[0])), decodedPath.hasPrefix("/") else {
|
|
throw RemoteConnectionURLParseError.invalidURL
|
|
}
|
|
var type: RemoteConnectionURL.SFTPType?
|
|
if pathAndParameters.count == 2 {
|
|
for parameter in pathAndParameters[1].split(separator: ",", omittingEmptySubsequences: false) {
|
|
let pair = parameter.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard pair.count == 2, !pair[0].isEmpty else {
|
|
throw RemoteConnectionURLParseError.invalidURL
|
|
}
|
|
guard pair[0] == "type" else { continue }
|
|
guard type == nil else { throw RemoteConnectionURLParseError.unsupportedParameter }
|
|
if pair[1] == "a" { throw RemoteConnectionURLParseError.unsupportedSFTPTextMode }
|
|
guard let parsed = RemoteConnectionURL.SFTPType(rawValue: String(pair[1])) else {
|
|
throw RemoteConnectionURLParseError.unsupportedParameter
|
|
}
|
|
type = parsed
|
|
}
|
|
}
|
|
return (decodedPath.isEmpty ? "/" : decodedPath, type)
|
|
}
|
|
}
|
|
|
|
private static func parseFingerprint(_ value: String) throws -> SSHHostKeyConstraint {
|
|
let algorithm: SSHHostKeyConstraint.Algorithm
|
|
let digest: Substring
|
|
if value.hasPrefix("ssh-rsa-") {
|
|
algorithm = .rsa
|
|
digest = value.dropFirst("ssh-rsa-".count)
|
|
} else if value.hasPrefix("ssh-dss-") {
|
|
algorithm = .dsa
|
|
digest = value.dropFirst("ssh-dss-".count)
|
|
} else {
|
|
throw RemoteConnectionURLParseError.invalidFingerprint
|
|
}
|
|
let octets = digest.split(separator: "-", omittingEmptySubsequences: false)
|
|
guard octets.count == 16 else { throw RemoteConnectionURLParseError.invalidFingerprint }
|
|
var bytes: [UInt8] = []
|
|
bytes.reserveCapacity(16)
|
|
for octet in octets {
|
|
guard octet.count == 2, let byte = UInt8(octet, radix: 16) else {
|
|
throw RemoteConnectionURLParseError.invalidFingerprint
|
|
}
|
|
bytes.append(byte)
|
|
}
|
|
return SSHHostKeyConstraint(algorithm: algorithm, legacyMD5: Data(bytes))
|
|
}
|
|
|
|
private static func decode(_ value: String?) throws -> String? {
|
|
guard let value else { return nil }
|
|
guard let decoded = value.removingPercentEncoding,
|
|
!decoded.contains(where: { $0 == "\0" || $0.isNewline || $0.isASCII && $0.asciiValue! < 0x20 })
|
|
else { throw RemoteConnectionURLParseError.invalidURL }
|
|
return decoded
|
|
}
|
|
|
|
private static func nonempty(_ value: String?) throws -> String? {
|
|
guard let value else { return nil }
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
}
|