Files

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
}
}