Files
Termind/Frameworks/TermindDomain/SSHImport/SSHConfigParser.swift
T

224 lines
10 KiB
Swift

import Foundation
/// Evaluates each concrete alias against the original ordered file tree.
/// Includes share the current Host/Match scope, just as textual configuration does.
public nonisolated struct SSHConfigParser {
public let root: URL
public let home: URL
public let username: String
public let files: [String: String]
public let entryPaths: [String]
public var externalPaths: Set<String> = []
public var diagnostics: [SSHImportItem] = []
public func expandedPath(_ path: String, host: SSHImportedHost? = nil) -> String {
var value = path
let replacements = [
"%d": home.path, "%u": username, "%h": host?.hostname ?? "",
"%n": host?.alias ?? "", "%r": host?.username ?? username,
"%p": String(host?.port ?? 22),
]
value = value.replacingOccurrences(of: "%%", with: "\u{1}")
for (token, replacement) in replacements { value = value.replacingOccurrences(of: token, with: replacement) }
value = value.replacingOccurrences(of: "\u{1}", with: "%")
if value == "~/.ssh" {
value = root.path
} else if value.hasPrefix("~/.ssh/") {
value = root.appendingPathComponent(String(value.dropFirst(7))).path
} else if value.hasPrefix("~/") {
value = home.appendingPathComponent(String(value.dropFirst(2))).path
}
if !value.hasPrefix("/") { value = root.appendingPathComponent(value).path }
return URL(fileURLWithPath: value).standardizedFileURL.path
}
public mutating func parse() -> [SSHImportItem] {
var result: [SSHImportItem] = []
for config in rootEntryPaths() {
var aliases: [String] = []
discover(config, stack: [], aliases: &aliases)
for alias in aliases {
var state = Evaluation()
state.warnings = diagnostics.map { $0.source + ": " + $0.issue }
evaluate(config, alias: alias, stack: [], state: &state)
var host = SSHImportedHost(
alias: alias, hostname: state.values["hostname"] ?? alias,
username: state.values["user"] ?? username)
host.hostname = host.hostname.replacingOccurrences(of: "%h", with: alias).replacingOccurrences(
of: "%n", with: alias)
var issue = ""
if let value = state.values["port"] {
if let port = Int(value), (1...65_535).contains(port) {
host.port = port
} else {
issue = String(localized: "Invalid SSH port.")
}
}
let identities =
state.identities.isEmpty
? ["~/.ssh/id_rsa", "~/.ssh/id_ecdsa", "~/.ssh/id_ed25519"] : state.identities
host.usesDefaultIdentityFiles = state.identities.isEmpty
host.identityPaths = identities.filter { $0 != "none" }.map { expandedPath($0, host: host) }
host.certificatePaths = state.certificates.filter { $0 != "none" }.map { expandedPath($0, host: host) }
if let jump = state.values["proxyjump"], jump != "none" {
host.jumps = jump.split(separator: ",").map(String.init)
}
let source = state.source.isEmpty ? config : state.source
result.append(
SSHImportItem(
category: .hosts, name: alias, source: source,
issue: issue, warnings: state.warnings, host: host))
}
}
return result + diagnostics
}
/// A recursively discovered Include fragment is evaluated through its
/// parent, not a second time as an independent configuration root.
private mutating func rootEntryPaths() -> [String] {
let candidates = entryPaths.filter { files[$0] != nil }
guard !candidates.isEmpty else { return [] }
let candidateSet = Set(candidates)
var included: Set<String> = []
for path in candidates {
for line in lines(path) where line.keyword == "include" {
included.formUnion(includes(line).filter { candidateSet.contains($0) })
}
}
let roots = candidates.filter { !included.contains($0) }
return (roots.isEmpty ? candidates : roots).sorted()
}
private struct Evaluation {
public var active = true
public var inMatch = false
public var values: [String: String] = [:]
public var identities: [String] = []
public var certificates: [String] = []
public var warnings: [String] = []
public var source = ""
public init(
active: Bool = true, inMatch: Bool = false, values: [String: String] = [:], identities: [String] = [],
certificates: [String] = [], warnings: [String] = [], source: String = ""
) {
self.active = active
self.inMatch = inMatch
self.values = values
self.identities = identities
self.certificates = certificates
self.warnings = warnings
self.source = source
}
}
private mutating func lines(_ path: String) -> [SSHConfigLine] {
guard let content = files[path] else {
externalPaths.insert(path)
return []
}
return content.components(separatedBy: .newlines).enumerated().compactMap { index, raw in
do {
let tokens = try SSHImportParser.tokens(raw)
guard let keyword = tokens.first else { return nil }
guard tokens.count > 1 else { throw ImportParseError.invalid }
return SSHConfigLine(
keyword: keyword.lowercased(), values: Array(tokens.dropFirst()), source: path, line: index + 1)
} catch {
let location = "\(path):\(index + 1)"
if !diagnostics.contains(where: { $0.source == location }) {
diagnostics.append(
SSHImportItem(
category: .hosts, name: URL(fileURLWithPath: path).lastPathComponent,
source: location, selected: false, issue: String(localized: "Invalid SSH record."),
unavailable: true))
}
return nil
}
}
}
private mutating func includes(_ line: SSHConfigLine) -> [String] {
line.values.flatMap { value -> [String] in
let pattern = expandedPath(value)
if pattern.contains("*") || pattern.contains("?") {
let matches = files.keys.filter { SSHImportParser.glob(pattern, matches: $0, path: true) }.sorted()
if matches.isEmpty && !pattern.hasPrefix(root.path + "/") { externalPaths.insert(pattern) }
return matches
}
if files[pattern] == nil { externalPaths.insert(pattern) }
return [pattern]
}
}
private mutating func discover(_ path: String, stack: [String], aliases: inout [String]) {
guard !stack.contains(path), stack.count < 32 else {
diagnostics.append(
SSHImportItem(
category: .hosts, name: "Include", source: path, selected: false,
issue: String(localized: "Include cycle or nesting limit reached."), unavailable: true))
return
}
for line in lines(path) {
if line.keyword == "host" {
for alias in line.values where SSHImportParser.concrete(alias) && !aliases.contains(alias) {
aliases.append(alias)
}
} else if line.keyword == "include" {
for file in includes(line) { discover(file, stack: stack + [path], aliases: &aliases) }
}
}
}
private mutating func evaluate(_ path: String, alias: String, stack: [String], state: inout Evaluation) {
guard !stack.contains(path), stack.count < 32 else {
state.warnings.append(String(localized: "Include cycle or nesting limit reached."))
return
}
for line in lines(path) {
let location = "\(line.source):\(line.line)"
if line.keyword == "host" {
state.active = SSHImportParser.matches(line.values, alias: alias)
state.inMatch = false
if line.values.contains(alias) && state.source.isEmpty { state.source = location }
continue
}
if line.keyword == "match" {
state.inMatch = true
state.active = false
state.warnings.append("\(location): Match")
continue
}
guard state.active && !state.inMatch else { continue }
if line.keyword == "include" {
for file in includes(line) {
if files[file] == nil { state.warnings.append("\(location): Include \(file)") }
evaluate(file, alias: alias, stack: stack + [path], state: &state)
}
} else if ["hostname", "port", "user", "proxyjump"].contains(line.keyword) {
if state.values[line.keyword] == nil { state.values[line.keyword] = line.values.first }
} else if line.keyword == "identityfile" {
if let value = line.values.first, !state.identities.contains(value) { state.identities.append(value) }
} else if line.keyword == "certificatefile" {
if let value = line.values.first { state.certificates.append(value) }
} else {
state.warnings.append("\(location): \(line.keyword)")
}
}
}
public init(
root: URL, home: URL, username: String, files: [String: String], entryPaths: [String]? = nil,
externalPaths: Set<String> = [],
diagnostics: [SSHImportItem] = []
) {
self.root = root
self.home = home
self.username = username
self.files = files
self.entryPaths = entryPaths ?? [root.appendingPathComponent("config").path]
self.externalPaths = externalPaths
self.diagnostics = diagnostics
}
}