Files

132 lines
6.3 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
/// A bounded, read-only exec operation on the terminal's authenticated connection.
public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
public static let maximumFileBytes = 2 * 1024 * 1024
private let execute: @Sendable (String, Int) async throws -> SSHExecResult
private let resolveSource: @Sendable () async throws -> ShellHistorySource?
private let charset: RemoteCharset
public init(channel: SSHPTYChannel, charset: RemoteCharset) {
self.init(
charset: charset,
resolveSource: { try await channel.historySource() },
execute: { try await channel.executeHistoryRead($0, outputLimit: $1) })
}
public init(channel: SSHPTYChannel, charset: RemoteCharset, context: TerminalShellContext) {
self.init(
charset: charset,
resolveSource: {
guard let source = context.historySource else { throw TerminalRuntimeError.staleContext }
return source
},
execute: { try await channel.executeHistoryRead($0, outputLimit: $1, context: context) })
}
init(
charset: RemoteCharset,
resolveSource: @escaping @Sendable () async throws -> ShellHistorySource?,
execute: @escaping @Sendable (String, Int) async throws -> SSHExecResult
) {
self.charset = charset
self.resolveSource = resolveSource
self.execute = execute
}
public func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
try await withThrowingTaskGroup(of: [ShellHistoryRecord].self) { group in
group.addTask { try await read(source: source) }
group.addTask {
try await Task.sleep(for: .seconds(10))
throw TermindSSHError.library("History read timed out.")
}
defer { group.cancelAll() }
return try await group.next() ?? []
}
}
private func read(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
let resolved: ShellHistorySource?
if let source { resolved = source } else { resolved = try await resolveSource() }
if resolved?.path == "" { return [] }
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let result = try await executeRead(Self.command(source: resolved, token: token))
guard result.exitCode == 0, !result.stdoutTruncated else {
throw TermindSSHError.library("History file could not be read.")
}
guard let start = result.stdout.range(of: "__TRMD_HISTORY_\(token)__"),
let lineEnd = result.stdout[start.upperBound...].firstIndex(of: "\n"),
let end = result.stdout.range(of: "__TRMD_HISTORY_END_\(token)__", range: lineEnd..<result.stdout.endIndex)
else { throw TermindSSHError.library("History response was incomplete.") }
let fields = result.stdout[start.upperBound..<lineEnd].split(separator: ";", omittingEmptySubsequences: false)
guard fields.count == 2, let size = Int(fields[1]), size >= 0 else {
throw TermindSSHError.library("History response was invalid.")
}
if size == 0 { return [] }
guard let shell = ShellHistorySource.Shell(rawValue: String(fields[0])),
var bytes = Data(
base64Encoded: String(result.stdout[lineEnd..<end.lowerBound]), options: .ignoreUnknownCharacters),
bytes.count <= Self.maximumFileBytes
else { throw TermindSSHError.library("History response was invalid.") }
if shell == .zsh { bytes = Self.unmetafy(bytes) }
let text = try RemoteCharsetStreamConverter.decode(bytes, charset: charset)
return Array(
ShellHistoryParser.parse(text, shell: shell, truncatedStart: size > Self.maximumFileBytes)
.suffix(HistoryLimits.retainedEntries))
}
private func executeRead(_ command: String) async throws -> SSHExecResult {
// A runtime poll may briefly own the last free channel. Never queue behind the user's PTY.
for attempt in 0..<3 {
try Task.checkCancellation()
do {
return try await execute(command, 3 * 1024 * 1024)
} catch TerminalRuntimeError.capacityUnavailable where attempt < 2 {
try await Task.sleep(for: .milliseconds(100 * (attempt + 1)))
}
}
throw TerminalRuntimeError.capacityUnavailable
}
static func command(source: ShellHistorySource?, token: String) -> String {
let shell = source.map { quote($0.shell.rawValue) } ?? "\"${SHELL##*/}\""
let path =
source?.path.map { "file=\(quote($0));" }
?? #"case "$kind" in bash) file=${HISTFILE-$HOME/.bash_history} ;; zsh) file=${HISTFILE-${ZDOTDIR-$HOME}/.zsh_history} ;; fish) name=${fish_history-fish}; if [ -n "$name" ]; then file=${XDG_DATA_HOME-$HOME/.local/share}/fish/${name}_history; else file=; fi ;; *) file= ;; esac;"#
// The file is data, never shell source. Base64 preserves zsh metafied and non-UTF-8 bytes
// across the ordinary exec charset decoder. Framing excludes login/profile chatter.
return """
kind=\(shell); \(path)
if [ -z "$file" ] || [ ! -e "$file" ]; then
printf '__TRMD_HISTORY_\(token)__%s;0\\n__TRMD_HISTORY_END_\(token)__\\n' "$kind"; exit 0
fi
[ -f "$file" ] && [ -r "$file" ] || exit 1
exec 3< "$file" || exit 1
size=$(wc -c < "$file") || exit 1
size=$(printf '%s' "$size" | tr -d '[:space:]')
printf '__TRMD_HISTORY_\(token)__%s;%s\\n' "$kind" "$size"
tail -c \(maximumFileBytes) <&3 | base64 || exit 1
printf '\\n__TRMD_HISTORY_END_\(token)__\\n'
"""
}
private static func quote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
static func unmetafy(_ input: Data) -> Data {
var output = Data()
var iterator = input.makeIterator()
while let byte = iterator.next() {
if byte == 0x83, let next = iterator.next() { output.append(next ^ 0x20) } else { output.append(byte) }
}
return output
}
}