Files
Termind/FrameworkTests/RemoteShellHistoryLoaderTests.swift

174 lines
8.3 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
#if os(macOS)
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
@Suite
struct RemoteShellHistoryLoaderTests {
@Test func explicitlyDisabledHistoryDoesNotOpenAControlChannel() async throws {
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { .init(shell: .bash, path: "") },
execute: { _, _ in
Issue.record("Disabled history must not read a default file")
throw TerminalRuntimeError.capacityUnavailable
})
#expect(try await loader.load(source: nil).isEmpty)
}
@Test(arguments: [false, true])
func channelCapacityRetriesAreBoundedAndCanRecover(permanentlyUnavailable: Bool) async throws {
let attempts = ReadAttempts()
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { .init(shell: .bash, path: "/nonexistent-\(UUID())") },
execute: { command, _ in
let attempt = await attempts.next()
if permanentlyUnavailable || attempt < 3 { throw TerminalRuntimeError.capacityUnavailable }
return try Self.execute(command)
})
if permanentlyUnavailable {
await #expect(throws: TerminalRuntimeError.self) { try await loader.load(source: nil) }
} else {
#expect(try await loader.load(source: nil).isEmpty)
}
#expect(await attempts.count == 3)
}
private actor ReadAttempts {
private(set) var count = 0
func next() -> Int {
count += 1
return count
}
}
@Test func realZshHistoryPreservesBackslashesBeforeLogicalNewlines() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
let commands =
(0...2).map { "echo one" + String(repeating: "\\", count: $0) + "\ntwo" }
+ ["echo end\\", "pwd"]
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments =
[
"-f", "-i", "-c",
"HISTFILE=$1; HISTSIZE=100; SAVEHIST=100; setopt EXTENDED_HISTORY; shift; "
+ "for c in \"$@\"; do print -sr -- \"$c\"; done; fc -W \"$HISTFILE\"",
"history-test", file.path,
] + commands
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil }, execute: { command, _ in try Self.execute(command) })
let records = try await loader.load(source: .init(shell: .zsh, path: file.path))
#expect(records.map(\.command) == Array(commands.prefix(3)) + ["echo end\\ ", "pwd"])
}
@Test func execReadsQuotedPathsAsDataAndWorksWithoutIntegration() async throws {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: folder) }
let file = folder.appendingPathComponent("history ' quoted; $(false)")
try Data("pwd\necho $(false)\n".utf8).write(to: file)
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command, environment: ["SHELL": "/bin/bash", "HISTFILE": file.path])
})
let standard = try await loader.load(source: nil)
let custom = try await loader.load(source: .init(shell: .bash, path: file.path))
#expect(standard.map(\.command) == ["pwd", "echo $(false)"])
#expect(custom == standard)
#expect(standard.allSatisfy { $0.executedAt == nil })
#expect(try String(contentsOf: file, encoding: .utf8) == "pwd\necho $(false)\n")
}
@Test func readsOnlyBoundedTailAndKeepsCompleteRecentRecords() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
var content = Data(String(repeating: "x", count: RemoteShellHistoryLoader.maximumFileBytes + 100).utf8)
content.append(Data("\n#123\npwd\n#124\nwhoami\n".utf8))
try content.write(to: file)
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command)
})
let records = try await loader.load(source: .init(shell: .bash, path: file.path))
#expect(records.map(\.command) == ["pwd", "whoami"])
}
@Test func missingAndExplicitlyDisabledFilesAreSuccessfulEmptyReads() async throws {
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command)
})
#expect(try await loader.load(source: .init(shell: .zsh, path: "/nonexistent-\(UUID())")).isEmpty)
#expect(try await loader.load(source: .init(shell: .bash, path: "")).isEmpty)
}
@Test func zshMetaficationAndRemoteCharsetAreDecodedBeforeParsing() async throws {
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
let bytes = try RemoteCharsetStreamConverter.encode(": 123:0;echo 中文\n", charset: .gb18030)
var metafied = Data()
for byte in bytes {
if byte >= 0x80 {
metafied.append(0x83)
metafied.append(byte ^ 0x20)
} else {
metafied.append(byte)
}
}
try metafied.write(to: file)
let loader = RemoteShellHistoryLoader(
charset: .gb18030, resolveSource: { nil },
execute: { command, _ in
try Self.execute(command)
})
#expect(try await loader.load(source: .init(shell: .zsh, path: file.path)).map(\.command) == ["echo 中文"])
}
@Test func execFailureDoesNotReturnAPartialSnapshot() async {
let loader = RemoteShellHistoryLoader(
charset: .utf8, resolveSource: { nil },
execute: { _, _ in
SSHExecResult(
stdout: "partial", stderr: "denied", exitCode: 1, stdoutTruncated: false, stderrTruncated: false
)
})
await #expect(throws: (any Error).self) { try await loader.load(source: nil) }
}
private nonisolated static func execute(_ command: String, environment: [String: String] = [:]) throws
-> SSHExecResult
{
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, replacement in
replacement
}
let output = Pipe()
process.standardOutput = output
process.standardError = FileHandle.nullDevice
try process.run()
let bytes = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return SSHExecResult(
stdout: String(decoding: bytes, as: UTF8.self), stderr: "", exitCode: process.terminationStatus,
stdoutTruncated: false, stderrTruncated: false)
}
}
#endif