mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
The root system-proxy helper logged nothing and launchd discards its stdout/stderr, leaving helper launches, connection refusals, GUI-side timeouts and root-side failures invisible. Share the minimal unified-log emission unit (SystemLog) across GUI, wrapper and helper, and add a dedicated closed helper event enum (lifecycle minimum: listener ready, connection accepted/rejected with peer pid, typed apply/clear results) with ADR-0045 privacy splitting. The emission seam is injected at the helper engine and listener for test doubles. Helper events are Console-only: no stderr, no agent.log, no diagnostic view or report surface (ADR-0047).
217 lines
9.2 KiB
Swift
217 lines
9.2 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// 特权 helper 事件的统一日志路由与发射缝测试(ADR-0047):route 是纯
|
|
/// 函数,断言 level/category/公开文本与 privacy 拆分;engine 与 listener
|
|
/// 经注入替身断言发射调用。测试不写真实系统日志 store(scheme 注入
|
|
/// SSXNG_SYSTEM_LOG=0),也不触真实 SystemConfiguration。
|
|
@Suite struct SystemProxyHelperLogTests {
|
|
private let configuration = SystemProxyConfiguration(
|
|
socks: .init(host: "127.0.0.1", port: 1086),
|
|
http: .init(host: "127.0.0.1", port: 1087),
|
|
https: .init(host: "127.0.0.1", port: 1087),
|
|
exceptions: ["localhost"])
|
|
|
|
/// 并发闭包与断言之间的线程安全事件交接。
|
|
private final class EventRecorder: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var events: [SystemProxyHelperLogEvent] = []
|
|
|
|
func append(_ event: SystemProxyHelperLogEvent) {
|
|
lock.lock()
|
|
events.append(event)
|
|
lock.unlock()
|
|
}
|
|
|
|
var all: [SystemProxyHelperLogEvent] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return events
|
|
}
|
|
}
|
|
|
|
// MARK: - route(纯函数)
|
|
|
|
@Test func lifecycleEventsRouteToSystemProxyCategory() {
|
|
let ready = SystemProxyHelperLogEvent.listenerReady.logRoute
|
|
#expect(ready.level == .default)
|
|
#expect(ready.category == "system-proxy")
|
|
#expect(ready.text == "system proxy helper listening")
|
|
#expect(ready.privateDetail == nil)
|
|
|
|
let accepted = SystemProxyHelperLogEvent.connectionAccepted(pid: 42).logRoute
|
|
#expect(accepted.level == .default)
|
|
#expect(accepted.text == "system proxy helper connection accepted (pid=42)")
|
|
#expect(accepted.privateDetail == nil)
|
|
|
|
let rejected = SystemProxyHelperLogEvent.connectionRejected(pid: 7).logRoute
|
|
#expect(rejected.level == .error)
|
|
#expect(rejected.text == "system proxy helper connection rejected (pid=7)")
|
|
#expect(rejected.privateDetail == nil)
|
|
}
|
|
|
|
@Test func requestResultsRouteAsMilestones() {
|
|
let written = SystemProxyHelperLogEvent.applyCompleted(.written).logRoute
|
|
#expect(written.level == .default)
|
|
#expect(written.text == "system proxy helper apply completed (outcome=written)")
|
|
|
|
let unchanged = SystemProxyHelperLogEvent.applyCompleted(.unchanged).logRoute
|
|
#expect(unchanged.level == .default)
|
|
#expect(unchanged.text == "system proxy helper apply completed (outcome=unchanged)")
|
|
|
|
let cleared = SystemProxyHelperLogEvent.clearCompleted.logRoute
|
|
#expect(cleared.level == .default)
|
|
#expect(cleared.text == "system proxy helper clear completed")
|
|
}
|
|
|
|
@Test func requestFailureKeepsFreeDetailPrivate() {
|
|
let event = SystemProxyHelperLogEvent.requestFailed(
|
|
.commitFailed("SCPreferencesCommitChanges: busy"))
|
|
let route = event.logRoute
|
|
#expect(route.level == .error)
|
|
#expect(route.category == "system-proxy")
|
|
#expect(route.text == "system proxy helper request failed: commit failed: ")
|
|
#expect(route.privateDetail == "SCPreferencesCommitChanges: busy")
|
|
#expect(route.text + route.privateDetail! == event.description, "text+detail == description")
|
|
}
|
|
|
|
@Test func requestFailureWithoutDetailHasNoPrivateSegment() {
|
|
let event = SystemProxyHelperLogEvent.requestFailed(.preferencesBusy)
|
|
#expect(event.logRoute.text == "system proxy helper request failed: preferences busy")
|
|
#expect(event.logRoute.privateDetail == nil)
|
|
#expect(event.description == "system proxy helper request failed: preferences busy")
|
|
}
|
|
|
|
@Test func errorCasesSplitDetailFromPublicText() {
|
|
#expect(SystemProxyError.unreadableService("svc-1").logParts.text == "unreadable service")
|
|
#expect(SystemProxyError.unreadableService("svc-1").logParts.detail == "svc-1")
|
|
#expect(SystemProxyError.invalidRequest("bad json").logParts.detail == "bad json")
|
|
#expect(SystemProxyError.helperUnavailable("timeout").logParts.detail == "timeout")
|
|
#expect(SystemProxyError.preferencesUnavailable.logParts.detail == nil)
|
|
#expect(SystemProxyError.noProxyServices.logParts.detail == nil)
|
|
#expect(SystemProxyError.noCurrentNetworkSet.logParts.detail == nil)
|
|
#expect(SystemProxyError.noNetworkLocations.logParts.detail == nil)
|
|
#expect(SystemProxyError.invalidStoredConfiguration("svc-2").logParts.detail == "svc-2")
|
|
#expect(SystemProxyError.cannotWriteService("svc-3").logParts.detail == "svc-3")
|
|
#expect(SystemProxyError.applyFailed("SCError").logParts.detail == "SCError")
|
|
}
|
|
|
|
@Test func eventsDeriveFromTypedResponses() {
|
|
#expect(SystemProxyHelperLogEvent(response: .applied(.written)) == .applyCompleted(.written))
|
|
#expect(SystemProxyHelperLogEvent(response: .cleared) == .clearCompleted)
|
|
#expect(
|
|
SystemProxyHelperLogEvent(response: .failure(.noProxyServices))
|
|
== .requestFailed(.noProxyServices))
|
|
}
|
|
|
|
// MARK: - 发射缝(engine 注入替身)
|
|
|
|
@Test func engineEmitsTypedApplyResult() async throws {
|
|
let recorder = EventRecorder()
|
|
let engine = SystemProxyHelperEngine(perform: { _ in .applied(.written) }, log: recorder.append)
|
|
let payload = try SystemProxyHelperWire.encodeConfiguration(configuration)
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
engine.apply(payload) { _ in continuation.resume() }
|
|
}
|
|
#expect(recorder.all == [.applyCompleted(.written)])
|
|
}
|
|
|
|
@Test func engineEmitsClearResult() async {
|
|
let recorder = EventRecorder()
|
|
let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append)
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
engine.clear { _ in continuation.resume() }
|
|
}
|
|
#expect(recorder.all == [.clearCompleted])
|
|
}
|
|
|
|
@Test func engineEmitsFailureForUndecodablePayload() async {
|
|
let recorder = EventRecorder()
|
|
let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append)
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
engine.apply(Data("not json".utf8)) { _ in continuation.resume() }
|
|
}
|
|
guard case .requestFailed(let error) = recorder.all.first else {
|
|
Issue.record("无效 payload 应发射 requestFailed,实际 \(recorder.all)")
|
|
return
|
|
}
|
|
#expect(error.logParts.text == "invalid request")
|
|
#expect(error.logParts.detail != nil)
|
|
}
|
|
|
|
// MARK: - 发射缝(listener 注入替身,真实匿名 XPC)
|
|
|
|
@Test func listenerEmitsAcceptedConnectionBeforeRequestResult() async throws {
|
|
let recorder = EventRecorder()
|
|
let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append)
|
|
let delegate = SystemProxyHelperListenerDelegate(
|
|
engine: engine,
|
|
expectedClientIdentifier: try #require(ProcessSigningIdentifier.current()),
|
|
log: recorder.append)
|
|
let listener = NSXPCListener.anonymous()
|
|
listener.delegate = delegate
|
|
listener.resume()
|
|
defer { listener.invalidate() }
|
|
|
|
let connection = NSXPCConnection(listenerEndpoint: listener.endpoint)
|
|
connection.remoteObjectInterface = NSXPCInterface(with: SystemProxyHelperControlling.self)
|
|
connection.resume()
|
|
defer { connection.invalidate() }
|
|
|
|
let proxy = try #require(
|
|
connection.remoteObjectProxyWithErrorHandler { _ in } as? SystemProxyHelperControlling)
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
proxy.clear { _ in continuation.resume() }
|
|
}
|
|
#expect(recorder.all == [.connectionAccepted(pid: getpid()), .clearCompleted])
|
|
}
|
|
|
|
@Test func listenerEmitsRejectedConnection() async {
|
|
let recorder = EventRecorder()
|
|
let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append)
|
|
let delegate = SystemProxyHelperListenerDelegate(
|
|
engine: engine,
|
|
expectedClientIdentifier: "com.example.not-the-client",
|
|
log: recorder.append)
|
|
let listener = NSXPCListener.anonymous()
|
|
listener.delegate = delegate
|
|
listener.resume()
|
|
defer { listener.invalidate() }
|
|
|
|
let connection = NSXPCConnection(listenerEndpoint: listener.endpoint)
|
|
connection.remoteObjectInterface = NSXPCInterface(with: SystemProxyHelperControlling.self)
|
|
// XPC 惰性连接:发一条消息才触发服务端签名校验;被拒绝的连接以失效
|
|
// (或代理错误回调)告终。超时兜底避免挂死,超时后事件断言自然失败。
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
let once = ResumeOnce(continuation)
|
|
connection.invalidationHandler = { once.resume() }
|
|
connection.resume()
|
|
let proxy = connection.remoteObjectProxyWithErrorHandler { _ in once.resume() }
|
|
(proxy as? SystemProxyHelperControlling)?.clear { _ in }
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 10) { once.resume() }
|
|
}
|
|
connection.invalidate()
|
|
#expect(recorder.all == [.connectionRejected(pid: getpid())])
|
|
}
|
|
}
|
|
|
|
/// 只恢复一次的 continuation 装箱:失效回调与超时兜底竞争同一恢复权。
|
|
private final class ResumeOnce: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var continuation: CheckedContinuation<Void, Never>?
|
|
|
|
init(_ continuation: CheckedContinuation<Void, Never>) {
|
|
self.continuation = continuation
|
|
}
|
|
|
|
func resume() {
|
|
lock.lock()
|
|
let taken = continuation
|
|
continuation = nil
|
|
lock.unlock()
|
|
taken?.resume()
|
|
}
|
|
}
|