Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift
T

322 lines
10 KiB
Swift

import Darwin
import XCTest
@testable import ShadowsocksX_NG2
/// 代理运行时测试共享夹具:临时运行时目录、契约文档构造、LaunchAgent
/// 与探测替身。
/// 跨替身共享的有序事件记录:断言「先清理系统代理、再停止监听」等次序。
final class ProxyRuntimeEventLog: @unchecked Sendable {
private let lock = NSLock()
private var items: [String] = []
func record(_ item: String) {
lock.lock()
defer { lock.unlock() }
items.append(item)
}
var events: [String] {
lock.lock()
defer { lock.unlock() }
return items
}
}
enum ProxyRuntimeFixture {
/// 在临时目录中建一套隔离的运行时文件组(不触碰真实 ~/Library)。
struct TemporaryRuntime {
let directory: URL
let contract: URL
let pidFile: URL
}
static func makeTemporaryRuntime(file: StaticString = #filePath, line: UInt = #line)
-> TemporaryRuntime
{
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ssxng-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return TemporaryRuntime(
directory: directory,
contract: directory.appendingPathComponent("sslocal-active.json"),
pidFile: directory.appendingPathComponent("agent.pid"))
}
@MainActor
static func catalogSnapshotReader(at fileURL: URL) -> RuntimeCatalogSnapshotReading {
CatalogCommitCoordinator.bootstrap(fileStore: makeTestCatalogStore(fileURL: fileURL))
.catalogSnapshotReader
}
static func unusedLoopbackPort() throws -> Int {
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
guard descriptor >= 0 else { throw POSIXError(.ENOTSOCK) }
defer { close(descriptor) }
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
address.sin_family = sa_family_t(AF_INET)
address.sin_port = 0
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let result = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard result == 0 else { throw POSIXError(.EADDRINUSE) }
var bound = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
let nameResult = withUnsafeMutablePointer(to: &bound) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(descriptor, $0, &length)
}
}
guard nameResult == 0 else { throw POSIXError(.EINVAL) }
return Int(CFSwapInt16BigToHost(bound.sin_port))
}
/// 夹具默认监听端口:测试进程内一次性分配的空闲端口对。不能固定 11086/
/// 11087(与 app 默认监听相同):宿主 app 在跑时其 sslocal 占住这两个端口,
/// wrapper 健康探测会误判「监听已建立」。进程内共享同一对端口而非逐调用
/// 分配,保证两次默认构造之间除显式参数外无差异——「仅服务器变化」类
/// 变更协议语义依赖这一点。
private static let defaultListenPorts: (socks: Int, http: Int) = {
func freePort() -> Int {
guard let port = try? unusedLoopbackPort() else {
preconditionFailure("夹具端口分配失败:socket 不可用(fd 耗尽?)")
}
return port
}
let socks = freePort()
var http = freePort()
while http == socks {
http = freePort()
}
return (socks, http)
}()
static func makeDocument(
serverAddress: String = "203.0.113.7",
password: String = "resolved-password",
pluginOpts: String? = nil,
listenerMode: ListenerMode = .disabled,
localPort: Int? = nil,
inboundProtocol: String = "socks"
) -> SslocalRuntimeDocument {
precondition(inboundProtocol == "socks")
let socksPort: Int
let httpPort: Int
if let localPort {
socksPort = localPort
httpPort = ProxyListeningConfiguration.defaultHTTPPort
} else {
socksPort = defaultListenPorts.socks
httpPort = defaultListenPorts.http
}
return SslocalRuntimeDocument(
servers: [
SslocalServerDocument(
id: "server-1",
server: serverAddress,
serverPort: 8388,
password: password,
method: "aes-256-gcm",
plugin: nil,
pluginOpts: pluginOpts
)
],
listen: ProxyListeningConfiguration(
listenerMode: listenerMode,
socksPort: socksPort,
httpPort: httpPort))
}
/// LaunchAgent 注册态可编程替身,记录全部调用。
final class FakeLaunchAgent: LaunchAgentControlling {
private(set) var currentStatus: LaunchAgentStatus
private(set) var registerCount = 0
private(set) var unregisterCount = 0
var registerError: Error?
/// register 成功后自动迁移到的状态(模拟 launchd 拉起)。
var statusAfterRegister: LaunchAgentStatus = .registered
/// 测试用的运行回执。生产 wrapper 对 ACL 实例需等 sslocal 绑定监听后才写。
var onRegister: (() -> Void)?
var onUnregister: (() -> Void)?
/// 可选共享事件日志(次序断言用)。
weak var eventLog: ProxyRuntimeEventLog?
init(initialStatus: LaunchAgentStatus = .notRegistered) {
currentStatus = initialStatus
}
var status: LaunchAgentStatus { currentStatus }
func setStatus(_ status: LaunchAgentStatus) {
currentStatus = status
}
func register() throws {
registerCount += 1
eventLog?.record("register")
if let registerError {
currentStatus = .registered
throw registerError
}
currentStatus = statusAfterRegister
onRegister?()
}
func unregister() throws {
unregisterCount += 1
eventLog?.record("unregister")
currentStatus = .notRegistered
onUnregister?()
}
}
/// 探测替身:按预设序列返回,序列耗尽后停留在最后一项。
final class FakeProbe: EndpointProbing, @unchecked Sendable {
private let lock = NSLock()
private var outcomes: [EndpointHealthProbe.Outcome]
private var requestedPorts: [Int] = []
private(set) var callCount = 0
init(outcomes: [EndpointHealthProbe.Outcome]) {
self.outcomes = outcomes
}
static func reachable() -> FakeProbe {
FakeProbe(outcomes: [.reachable])
}
static func refusing() -> FakeProbe {
FakeProbe(outcomes: [.refused(detail: "Connection refused")])
}
/// 运行中改写探测序列(「条件恢复后自动收敛」类测试用)。
func setOutcomes(_ newOutcomes: [EndpointHealthProbe.Outcome]) {
lock.lock()
defer { lock.unlock() }
outcomes = newOutcomes
}
var ports: [Int] {
lock.lock()
defer { lock.unlock() }
return requestedPorts
}
func probe(host: String, port: Int, timeout: TimeInterval) async
-> EndpointHealthProbe.Outcome
{
recordProbe(port: port)
}
private func recordProbe(port: Int) -> EndpointHealthProbe.Outcome {
lock.lock()
defer { lock.unlock() }
callCount += 1
requestedPorts.append(port)
guard let outcome = outcomes.first else { return .timedOut }
if outcomes.count > 1 {
outcomes.removeFirst()
}
return outcome
}
}
final class FakeFirewallChecker: FirewallStatusChecking, @unchecked Sendable {
private let lock = NSLock()
private var outcomes: [FirewallBlockStatus]
private var recordedURLs: [URL] = []
var checkedURLs: [URL] {
lock.lock()
defer { lock.unlock() }
return recordedURLs
}
init(_ result: FirewallBlockStatus = .permitted) {
outcomes = [result]
}
init(outcomes: [FirewallBlockStatus]) {
precondition(!outcomes.isEmpty)
self.outcomes = outcomes
}
func status(for executableURL: URL) async -> FirewallBlockStatus {
recordStatus(for: executableURL)
}
private func recordStatus(for executableURL: URL) -> FirewallBlockStatus {
lock.lock()
defer { lock.unlock() }
recordedURLs.append(executableURL)
let outcome = outcomes[0]
if outcomes.count > 1 { outcomes.removeFirst() }
return outcome
}
}
}
/// 每个夹具使用独立 defaults 域,最后一个引用释放时清理持久值。
private final class TestProxyUserDefaults: UserDefaults, @unchecked Sendable {
private let domain: String
init() {
domain = "ng2-proxy-control-tests-\(UUID().uuidString)"
super.init(suiteName: domain)!
}
deinit { removePersistentDomain(forName: domain) }
}
func makeTestProxyControlPreferences() -> ProxyControlPreferences {
ProxyControlPreferences(defaults: TestProxyUserDefaults())
}
final class InMemoryProxyListeningStore: ProxyListeningStoring {
var saved: ProxyListeningConfiguration?
func load() throws -> ProxyListeningConfiguration { saved ?? ProxyListeningConfiguration() }
func save(_ configuration: ProxyListeningConfiguration) throws {
try configuration.enforcePersistenceConstraints()
saved = configuration
}
}
extension ProxyRuntimeFixture {
static func controlFlowRuleSnapshot(_ source: RulesSource) throws
-> RuleSnapshot
{
switch source {
case .geolocationCN:
return rulesFixture(
source,
rules: [
ProxyRule(action: .direct, match: try RuleMatch(domainExact: "direct.example")),
ProxyRule(
action: .direct, match: try RuleMatch(domainSuffix: "fixture-direct.example")),
])
case .chinaIPv4:
return rulesFixture(
source,
rules: [
ProxyRule(action: .direct, match: try RuleMatch(ipv4CIDR: "1.0.0.0/24"))
])
case .gfwlist:
return rulesFixture(
source,
rules: [
ProxyRule(action: .proxy, match: try RuleMatch(domainSuffix: "proxy.example")),
ProxyRule(action: .direct, match: try RuleMatch(domainExact: "safe.proxy.example")),
])
case .custom, .fixed:
throw RuleSnapshotError.missing
}
}
}