mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
386 lines
16 KiB
Swift
386 lines
16 KiB
Swift
import Foundation
|
|
import Testing
|
|
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
@MainActor
|
|
struct PluginMappingCommitTests {
|
|
@Test func savedTransportModeUpdatesActiveRuntime() async throws {
|
|
let fixture = try PluginMappingCommitFixture()
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.group)
|
|
var record = try #require(fixture.plugins.userRecords.first { $0.names.contains("tool") })
|
|
record.mode = .tcpAndUDP
|
|
try fixture.plugins.commit([.save(record)])
|
|
_ = await fixture.nextApplication()
|
|
#expect(
|
|
fixture.files.loadDocument()?.servers.first { $0.plugin != nil }?.pluginMode == .tcpAndUDP)
|
|
#expect(fixture.controller.state == .running)
|
|
record.mode = .tcpOnly
|
|
try fixture.plugins.commit([.save(record)])
|
|
_ = await fixture.nextApplication()
|
|
#expect(fixture.files.loadDocument()?.servers.first { $0.plugin != nil }?.pluginMode == nil)
|
|
}
|
|
|
|
@Test func runtimeFailureDoesNotUndoSavedMapping() async throws {
|
|
let fixture = try PluginMappingCommitFixture()
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.group)
|
|
#expect(fixture.controller.state == .running)
|
|
try FileManager.default.removeItem(at: fixture.files.pidFileURL)
|
|
fixture.agent.registerError = NSError(domain: "PluginMappingCommitTests", code: 1)
|
|
try fixture.plugins.updatePlugin(program: "tool", path: fixture.second.path)
|
|
let outcome = await fixture.nextApplication()
|
|
guard case .failed = outcome else {
|
|
Issue.record("Expected runtime failure after the mapping was saved")
|
|
return
|
|
}
|
|
#expect(fixture.plugins.testMappings["tool"] == fixture.second.path)
|
|
let restored = PluginCatalog(store: fixture.store, inspector: CommitPluginInspector())
|
|
#expect(restored.resolve(program: "tool")?.path == fixture.second.path)
|
|
}
|
|
|
|
@Test func unrelatedAndIdenticalMappingsDoNotRestartHealthyRuntime() async throws {
|
|
let fixture = try PluginMappingCommitFixture()
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.group)
|
|
#expect(fixture.controller.state == .running)
|
|
let registered = fixture.agent.registerCount
|
|
let unregistered = fixture.agent.unregisterCount
|
|
let signals = fixture.signals.count
|
|
let deployed = try #require(fixture.files.readData())
|
|
try fixture.plugins.addPlugin(program: "unused", path: fixture.second.path)
|
|
_ = await fixture.nextApplication()
|
|
#expect(fixture.agent.registerCount == registered)
|
|
#expect(fixture.agent.unregisterCount == unregistered)
|
|
#expect(fixture.signals.count == signals)
|
|
#expect(fixture.files.readData() == deployed)
|
|
let generation = fixture.plugins.catalogSnapshot().generation
|
|
try fixture.plugins.updatePlugin(program: "unused", path: fixture.second.path)
|
|
await fixture.drainMainQueue()
|
|
#expect(fixture.plugins.catalogSnapshot().generation == generation)
|
|
#expect(fixture.agent.registerCount == registered)
|
|
#expect(fixture.agent.unregisterCount == unregistered)
|
|
#expect(fixture.signals.count == signals)
|
|
}
|
|
|
|
@Test func mappingRemovalRevalidatesGroupBeforeRuntimeApplication() async throws {
|
|
let fixture = try PluginMappingCommitFixture()
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.group)
|
|
#expect(fixture.controller.state == .running)
|
|
try fixture.plugins.removePlugin(program: "tool")
|
|
let group = try #require(fixture.workflow.tree.roots.first)
|
|
#expect(group.invalidDescendantCount == 1)
|
|
#expect(
|
|
group.find(fixture.pluginServer)?.invalidReasons == [.pluginNotProvided(program: "tool")])
|
|
#expect(group.find(fixture.plainServer)?.isInvalid == false)
|
|
_ = await fixture.nextApplication()
|
|
#expect(fixture.controller.activeTargetID == fixture.group)
|
|
#expect(
|
|
fixture.controller.skippedServers == [
|
|
SkippedServer(id: fixture.pluginServer, reason: .pluginNotProvided(program: "tool"))
|
|
])
|
|
let document = try #require(fixture.files.loadDocument())
|
|
#expect(document.servers.count == 1)
|
|
#expect(document.servers.first?.password == "pw-Plain")
|
|
#expect(fixture.controller.state == .running)
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func newerMappingWinsBeforeApplicationAndDuringHealthCheck(inFlight: Bool) async throws {
|
|
let fixture = try PluginMappingCommitFixture()
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.group)
|
|
#expect(fixture.controller.state == .running)
|
|
let started = XCTestExpectation(description: "mapping A entered health check")
|
|
if inFlight { fixture.probe.arm(started: started) }
|
|
try fixture.plugins.updatePlugin(program: "tool", path: fixture.second.path)
|
|
if inFlight {
|
|
let result = await XCTWaiter.fulfillment(of: [started], timeout: 3)
|
|
#expect(result == .completed)
|
|
}
|
|
try fixture.plugins.updatePlugin(program: "tool", path: fixture.third.path)
|
|
fixture.probe.release()
|
|
_ = await fixture.nextApplication()
|
|
if inFlight { _ = await fixture.nextApplication() }
|
|
#expect(fixture.plugins.testMappings["tool"] == fixture.third.path)
|
|
#expect(
|
|
try fixture.store.load().userPlugins.first { $0.names.contains("tool") }?.path
|
|
== fixture.third.path)
|
|
#expect(fixture.files.loadDocument()?.servers.first?.plugin == fixture.third.path)
|
|
#expect(fixture.controller.state == .running)
|
|
if !inFlight { #expect(fixture.applicationPaths == [fixture.third.path]) }
|
|
}
|
|
|
|
@Test func atomicWriteFailurePreservesCommittedFactsAndDoesNotApply() async throws {
|
|
let fixture = try PluginMappingCommitFixture()
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.group)
|
|
let generation = fixture.plugins.catalogSnapshot().generation
|
|
let tree = fixture.workflow.tree
|
|
let contract = fixture.files.readData()
|
|
let registered = fixture.agent.registerCount
|
|
let mappingsDirectory = fixture.store.fileURL.deletingLastPathComponent()
|
|
let backup = fixture.directory.appendingPathComponent("saved-mappings")
|
|
try FileManager.default.moveItem(at: mappingsDirectory, to: backup)
|
|
try Data("blocks temporary file creation".utf8).write(to: mappingsDirectory)
|
|
#expect(throws: (any Error).self) {
|
|
try fixture.plugins.updatePlugin(program: "tool", path: fixture.second.path)
|
|
}
|
|
#expect(fixture.plugins.testMappings["tool"] == fixture.first.path)
|
|
#expect(fixture.plugins.catalogSnapshot().generation == generation)
|
|
#expect(fixture.workflow.tree == tree)
|
|
#expect(fixture.agent.registerCount == registered)
|
|
#expect(fixture.files.readData() == contract)
|
|
await fixture.drainMainQueue()
|
|
#expect(fixture.applicationStarts == 0)
|
|
#expect(fixture.applicationPaths.isEmpty)
|
|
try FileManager.default.removeItem(at: mappingsDirectory)
|
|
try FileManager.default.moveItem(at: backup, to: mappingsDirectory)
|
|
#expect(
|
|
try fixture.store.load().userPlugins.first { $0.names.contains("tool") }?.path
|
|
== fixture.first.path)
|
|
}
|
|
|
|
}
|
|
|
|
private struct CommitPluginInspector: PluginInspecting {
|
|
func inspect(_ executable: URL) async -> PluginSecurityFacts {
|
|
PluginSecurityFacts(quarantine: .absent, signature: .notApplicable, policy: .notApplicable)
|
|
}
|
|
}
|
|
|
|
/// Production commit wiring with private files and controllable runtime effects.
|
|
@MainActor
|
|
private final class PluginMappingCommitFixture {
|
|
let directory: URL
|
|
let first: URL
|
|
let second: URL
|
|
let third: URL
|
|
let store: PluginMappingFileStore
|
|
let plugins: PluginCatalog
|
|
let files: RuntimeFileStore
|
|
let agent: ProxyRuntimeFixture.FakeLaunchAgent
|
|
let probe: ProxyRuntimeFixture.BlockingProbe
|
|
let signals: CommitSignals
|
|
let controller: ProxyRuntimeController
|
|
let workflow: CatalogWorkflow
|
|
let group: NodeID
|
|
let pluginServer: NodeID
|
|
let plainServer: NodeID
|
|
private(set) var applicationStarts = 0
|
|
private(set) var applicationPaths: [String] = []
|
|
private var applications: AsyncStream<RuntimeSyncOutcome>.Iterator
|
|
|
|
init() throws {
|
|
let setup = try makePluginMappingCommitSetup()
|
|
directory = setup.runtime.directory
|
|
first = setup.first
|
|
second = setup.second
|
|
third = setup.third
|
|
store = setup.store
|
|
plugins = setup.plugins
|
|
files = setup.files
|
|
agent = setup.agent
|
|
probe = setup.probe
|
|
signals = setup.signals
|
|
controller = setup.controller
|
|
workflow = setup.workflow
|
|
group = setup.group
|
|
pluginServer = setup.pluginServer
|
|
plainServer = setup.plainServer
|
|
let stream = AsyncStream<RuntimeSyncOutcome>.makeStream()
|
|
applications = stream.stream.makeAsyncIterator()
|
|
connectPluginCatalog(stream.continuation)
|
|
}
|
|
|
|
private func connectPluginCatalog(
|
|
_ continuation: AsyncStream<RuntimeSyncOutcome>.Continuation
|
|
) {
|
|
let controller = controller
|
|
let workflow = workflow
|
|
let files = files
|
|
plugins.connect(
|
|
invalidateRuntime: { [weak controller] in controller?.pluginMappingsDidPublish() },
|
|
revalidate: { [weak workflow] in workflow?.republishCommittedState() },
|
|
converge: { [weak self, weak controller] in
|
|
guard let controller else { return }
|
|
self?.applicationStarts += 1
|
|
let outcome = await controller.catalogDidCommit(
|
|
snapshot: controller.catalogSnapshotReader.catalogSnapshot)
|
|
if let path = files.loadDocument()?.servers.first?.plugin {
|
|
self?.applicationPaths.append(path)
|
|
}
|
|
continuation.yield(outcome)
|
|
})
|
|
}
|
|
|
|
func nextApplication() async -> RuntimeSyncOutcome? {
|
|
var iterator = applications
|
|
let outcome = await iterator.next(isolation: MainActor.shared)
|
|
applications = iterator
|
|
return outcome
|
|
}
|
|
|
|
/// Observe jobs already submitted to the main executor before asserting no application.
|
|
func drainMainQueue() async {
|
|
await withCheckedContinuation { continuation in
|
|
DispatchQueue.main.async { continuation.resume() }
|
|
}
|
|
}
|
|
|
|
func cleanUp() {
|
|
probe.release()
|
|
try? FileManager.default.removeItem(at: directory)
|
|
}
|
|
}
|
|
|
|
private final class CommitSignals: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var recorded = 0
|
|
|
|
var count: Int { lock.withLock { recorded } }
|
|
|
|
func record() { lock.withLock { recorded += 1 } }
|
|
}
|
|
|
|
@MainActor
|
|
private struct PluginMappingCommitSetup {
|
|
let runtime: ProxyRuntimeFixture.TemporaryRuntime
|
|
let first: URL
|
|
let second: URL
|
|
let third: URL
|
|
let store: PluginMappingFileStore
|
|
let plugins: PluginCatalog
|
|
let files: RuntimeFileStore
|
|
let agent: ProxyRuntimeFixture.FakeLaunchAgent
|
|
let probe: ProxyRuntimeFixture.BlockingProbe
|
|
let signals: CommitSignals
|
|
let controller: ProxyRuntimeController
|
|
let workflow: CatalogWorkflow
|
|
let group: NodeID
|
|
let pluginServer: NodeID
|
|
let plainServer: NodeID
|
|
}
|
|
|
|
@MainActor
|
|
private struct PluginMappingCatalogSetup {
|
|
let fileStore: CatalogFileStore
|
|
let bootstrap: CatalogCommitBootstrap
|
|
let group: NodeID
|
|
let pluginServer: NodeID
|
|
let plainServer: NodeID
|
|
}
|
|
|
|
@MainActor
|
|
private struct PluginMappingControllerContext {
|
|
let directory: URL
|
|
let bootstrap: CatalogCommitBootstrap
|
|
let files: RuntimeFileStore
|
|
let plugins: PluginCatalog
|
|
let agent: ProxyRuntimeFixture.FakeLaunchAgent
|
|
let probe: ProxyRuntimeFixture.BlockingProbe
|
|
let signals: CommitSignals
|
|
}
|
|
|
|
@MainActor
|
|
private func makePluginMappingCommitSetup() throws -> PluginMappingCommitSetup {
|
|
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
|
|
let first = runtime.directory.appendingPathComponent("first")
|
|
let second = runtime.directory.appendingPathComponent("second")
|
|
let third = runtime.directory.appendingPathComponent("third")
|
|
try installPluginCommitExecutables([first, second, third])
|
|
let store = PluginMappingFileStore(
|
|
fileURL: runtime.directory.appendingPathComponent("mappings/plugins.json"))
|
|
let plugins = PluginCatalog(store: store, inspector: CommitPluginInspector())
|
|
try plugins.addPlugin(program: "tool", path: first.path)
|
|
let catalog = try makePluginMappingCatalogSetup(in: runtime.directory)
|
|
let files = RuntimeFileStore(fileURL: runtime.contract)
|
|
let agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
|
let probe = ProxyRuntimeFixture.BlockingProbe()
|
|
let signals = CommitSignals()
|
|
agent.onRegister = {
|
|
try? Data("42".utf8).write(to: files.pidFileURL)
|
|
try? files.writePublishedRuntimeReceipt(processID: 42)
|
|
}
|
|
let controller = makePluginMappingCommitController(
|
|
PluginMappingControllerContext(
|
|
directory: runtime.directory, bootstrap: catalog.bootstrap, files: files,
|
|
plugins: plugins, agent: agent, probe: probe, signals: signals))
|
|
let workflow = makeCatalogWorkflow(
|
|
coordinator: CatalogCommitCoordinator(
|
|
fileStore: catalog.fileStore, runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
|
bootstrap: catalog.bootstrap), plugins: plugins)
|
|
return PluginMappingCommitSetup(
|
|
runtime: runtime, first: first, second: second, third: third, store: store,
|
|
plugins: plugins, files: files, agent: agent, probe: probe, signals: signals,
|
|
controller: controller, workflow: workflow, group: catalog.group,
|
|
pluginServer: catalog.pluginServer, plainServer: catalog.plainServer)
|
|
}
|
|
|
|
private func installPluginCommitExecutables(_ binaries: [URL]) throws {
|
|
for binary in binaries {
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func makePluginMappingCatalogSetup(in directory: URL) throws -> PluginMappingCatalogSetup {
|
|
let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json"))
|
|
var catalog = ConfigurationCatalog()
|
|
let group = try catalog.addGroup("Group")
|
|
let pluginServer = try ActivationFixture.addPlainServer("Plugin", to: group, in: &catalog)
|
|
let plainServer = try ActivationFixture.addPlainServer("Plain", to: group, in: &catalog)
|
|
guard case .server(var fields) = try #require(catalog.entry(for: pluginServer)).kind else {
|
|
throw CocoaError(.fileReadCorruptFile)
|
|
}
|
|
fields.pluginProgram = "tool"
|
|
try catalog.updateServer(pluginServer, with: fields)
|
|
try store.save(CatalogDocument(catalog: catalog))
|
|
return PluginMappingCatalogSetup(
|
|
fileStore: store, bootstrap: CatalogCommitCoordinator.bootstrap(fileStore: store),
|
|
group: group, pluginServer: pluginServer, plainServer: plainServer)
|
|
}
|
|
|
|
@MainActor
|
|
private func makePluginMappingCommitController(
|
|
_ context: PluginMappingControllerContext
|
|
) -> ProxyRuntimeController {
|
|
let directory = context.directory
|
|
let bootstrap = context.bootstrap
|
|
let files = context.files
|
|
let plugins = context.plugins
|
|
let agent = context.agent
|
|
let probe = context.probe
|
|
let signals = context.signals
|
|
let contractURL = files.fileURL
|
|
return ProxyRuntimeController(
|
|
controlPreferences: makeTestProxyControlPreferences(),
|
|
catalogSnapshotReader: bootstrap.catalogSnapshotReader,
|
|
activationFileStore: ActivationStateFileStore(
|
|
fileURL: directory.appendingPathComponent("activation.json")),
|
|
runtimeFileStore: files, plugins: plugins,
|
|
listeningStore: InMemoryProxyListeningStore(),
|
|
customRuleStore: CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json")),
|
|
appBundle: AppArtifact.bundle,
|
|
ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot),
|
|
settingsRestore: RestoredProxyRuntimeSettings(
|
|
settings: ProxyRuntimeSettings(listen: ActivationFixture.listen, agentEnabled: true),
|
|
unreadableError: nil),
|
|
agent: agent, probe: probe, systemProxy: ProxyRuntimeFixture.FakeSystemProxy(),
|
|
systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(),
|
|
firewallChecker: ProxyRuntimeFixture.FakeFirewallChecker(),
|
|
launchHealthTimeoutSeconds: 0.2,
|
|
launchHealthRetryDelay: { try await Task.sleep(for: .milliseconds(10)) },
|
|
sendSignal: { _, signal in
|
|
guard signal != 0 else { return 0 }
|
|
signals.record()
|
|
let store = RuntimeFileStore(fileURL: contractURL)
|
|
try? store.writePublishedRuntimeReceipt(processID: 42)
|
|
return 0
|
|
}, processIsAlive: { $0 == 42 })
|
|
}
|