mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:54:50 +00:00
361 lines
18 KiB
Swift
361 lines
18 KiB
Swift
import Foundation
|
|
import Testing
|
|
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
struct QuietPluginInspector: PluginInspecting {
|
|
func inspect(_ executable: URL) async -> PluginSecurityFacts {
|
|
PluginSecurityFacts(quarantine: .absent, signature: .notApplicable, policy: .notApplicable)
|
|
}
|
|
}
|
|
|
|
struct PluginCatalogTests {
|
|
@Test func invalidOverrideDoesNotUseManagedExecutable() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let binary = root.appendingPathComponent("Contents/Helpers/Plugins/v2ray-plugin")
|
|
try FileManager.default.createDirectory(
|
|
at: binary.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let snapshot = PluginCatalogSnapshot(
|
|
mappings: ["v2ray-plugin": root.appendingPathComponent("missing").path],
|
|
managed: BundleManagedPluginProvider(bundleURL: root))
|
|
#expect(snapshot.resolve(program: "v2ray-plugin")?.path == nil)
|
|
#expect(snapshot.entry(for: "v2ray-plugin")?.source == .user)
|
|
}
|
|
@Test @MainActor func savedMappingIsUsedAndRemovalRemovesCustomName() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let binary = root.appendingPathComponent("custom-plugin")
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json"))
|
|
let catalog = PluginCatalog(store: store, inspector: QuietPluginInspector())
|
|
try catalog.addPlugin(program: "custom-plugin", path: binary.path)
|
|
#expect(catalog.catalogSnapshot().resolve(program: "custom-plugin")?.path == binary.path)
|
|
#expect(
|
|
PluginCatalog(store: store, inspector: QuietPluginInspector()).catalogSnapshot().entry(
|
|
for: "custom-plugin")?.source == .user)
|
|
try catalog.removePlugin(program: "custom-plugin")
|
|
#expect(catalog.catalogSnapshot().entry(for: "custom-plugin") == nil)
|
|
}
|
|
|
|
@Test @MainActor func userPluginCanBeSavedThroughServerFormInterface() async throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let binary = root.appendingPathComponent("custom-plugin")
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let plugins = PluginCatalog(
|
|
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
|
|
inspector: QuietPluginInspector())
|
|
try plugins.addPlugin(program: "custom-plugin", path: binary.path)
|
|
let workflow = makeCatalogWorkflow(
|
|
coordinator: CatalogCommitCoordinator(
|
|
fileStore: makeTestCatalogStore(fileURL: root.appendingPathComponent("catalog.json")),
|
|
runtime: FakeCatalogRuntime()), plugins: plugins)
|
|
let id = try await workflow.createServer(
|
|
ServerEditDraft(
|
|
address: "127.0.0.1", port: 8388, encryptionMethod: "aes-256-gcm", password: "pw",
|
|
remark: "Custom", plugin: .named(program: "custom-plugin"), pluginOptions: "flag"),
|
|
into: nil)
|
|
#expect(try workflow.serverEditForm(for: id)?.plugin.options == "flag")
|
|
let presentation = try #require(workflow.serverFormPresentation(for: id))
|
|
#expect(
|
|
presentation.plugin.programs.contains { $0.program == "custom-plugin" && $0.source == .user })
|
|
}
|
|
|
|
@Test @MainActor func commitPublishesBeforeSchedulingRuntimeApplication() async throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let binary = root.appendingPathComponent("custom-plugin")
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let catalog = PluginCatalog(
|
|
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
|
|
inspector: QuietPluginInspector())
|
|
var events: [String] = []
|
|
let applied = AsyncStream<Void>.makeStream()
|
|
catalog.connect(
|
|
invalidateRuntime: { events.append("invalidate") },
|
|
revalidate: { events.append("publish") },
|
|
converge: {
|
|
events.append("apply")
|
|
applied.continuation.yield(())
|
|
})
|
|
try catalog.addPlugin(program: "custom-plugin", path: binary.path)
|
|
#expect(events == ["invalidate", "publish"])
|
|
for await _ in applied.stream { break }
|
|
#expect(events == ["invalidate", "publish", "apply"])
|
|
}
|
|
|
|
@Test @MainActor func corruptionBlocksManagedResolutionUntilExplicitRepair() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let binary = root.appendingPathComponent("Contents/Helpers/Plugins/v2ray-plugin")
|
|
try FileManager.default.createDirectory(
|
|
at: binary.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let file = root.appendingPathComponent("plugins.json")
|
|
try Data("broken".utf8).write(to: file)
|
|
let catalog = PluginCatalog(
|
|
store: PluginMappingFileStore(fileURL: file),
|
|
managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector())
|
|
#expect(catalog.catalogSnapshot().mappingsUnreadable)
|
|
let workflow = makeCatalogWorkflow(
|
|
coordinator: CatalogCommitCoordinator(
|
|
fileStore: makeTestCatalogStore(fileURL: root.appendingPathComponent("catalog.json")),
|
|
runtime: FakeCatalogRuntime()), plugins: catalog)
|
|
#expect(
|
|
workflow.newFormPluginSection(selection: .named(program: "v2ray-plugin")).mappingsUnreadable)
|
|
|
|
#expect(catalog.resolve(program: "v2ray-plugin")?.path == nil)
|
|
#expect(catalog.catalogSnapshot().entry(for: "v2ray-plugin")?.source == .unknown)
|
|
#expect(throws: PluginMappingError.unreadable) { try catalog.commit([]) }
|
|
try catalog.replaceTestMappings([:])
|
|
#expect(catalog.resolve(program: "v2ray-plugin")?.path == binary.path)
|
|
}
|
|
|
|
@Test @MainActor func failedPersistenceDoesNotPublishOrInvalidateRuntime() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let binary = root.appendingPathComponent("tool")
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let catalog = PluginCatalog(store: FailingMappingStore(), inspector: QuietPluginInspector())
|
|
var published = false
|
|
catalog.connect(
|
|
invalidateRuntime: { published = true }, revalidate: { published = true }, converge: {})
|
|
#expect(throws: PluginMappingError.unreadable) {
|
|
try catalog.addPlugin(program: "tool", path: binary.path)
|
|
}
|
|
#expect(!published)
|
|
#expect(catalog.catalogSnapshot().generation == 0)
|
|
#expect(catalog.catalogSnapshot().entry(for: "tool") == nil)
|
|
}
|
|
|
|
@Test @MainActor func symlinkAndCaseSensitiveNamesSurviveAtomicRename() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let binary = root.appendingPathComponent("tool")
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let link = root.appendingPathComponent("link")
|
|
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: binary)
|
|
let catalog = PluginCatalog(
|
|
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
|
|
inspector: QuietPluginInspector())
|
|
try catalog.addPlugin(program: " Tool ", path: link.path)
|
|
#expect(catalog.resolve(program: "Tool")?.path == link.path)
|
|
#expect(catalog.resolve(program: "tool")?.path == nil)
|
|
let captured = catalog.catalogSnapshot()
|
|
try catalog.renamePlugin(program: "Tool", newProgram: "Other", path: link.path)
|
|
#expect(catalog.resolve(program: "Tool")?.path == nil)
|
|
#expect(captured.resolve(program: "Tool")?.path == link.path)
|
|
try FileManager.default.removeItem(at: binary)
|
|
#expect(catalog.resolve(program: "Other")?.path == nil)
|
|
try catalog.removePlugin(program: "Other")
|
|
#expect(catalog.catalogSnapshot().entry(for: "Other") == nil)
|
|
}
|
|
|
|
@Test @MainActor func mappingAdoptionRefreshesOnlyPluginDraft() throws {
|
|
let id = NodeID.fresh()
|
|
let fields = ServerFormFields()
|
|
var plugin = PluginSectionState(
|
|
selection: .unknown(program: "tool"), programs: [], mappingsUnreadable: false,
|
|
optionsPresent: true, options: "")
|
|
let original = ServerEditForm(
|
|
address: "127.0.0.1", port: 8388, encryptionMethod: "aes-256-gcm", password: "pw",
|
|
remark: "Original", plugin: plugin, isEditable: true)
|
|
fields.showServer(id) { _ in original }
|
|
fields.remark = "Unsaved name"
|
|
plugin = PluginSectionState(
|
|
selection: .named(program: "tool"),
|
|
programs: [.init(program: "tool", source: .user, availability: .available)],
|
|
mappingsUnreadable: false,
|
|
optionsPresent: true, options: "flag")
|
|
let mapped = ServerEditForm(
|
|
address: original.address, port: original.port, encryptionMethod: original.encryptionMethod,
|
|
password: original.password, remark: original.remark, plugin: plugin, isEditable: true)
|
|
fields.updatePresentation(
|
|
ServerFormPresentation(isEditable: true, plugin: plugin), load: { _ in mapped })
|
|
#expect(fields.remark == "Unsaved name")
|
|
#expect(fields.pluginChoice == .named(program: "tool"))
|
|
#expect(fields.pluginOptions.composedString == "flag")
|
|
#expect(fields.hasChanges)
|
|
}
|
|
|
|
@Test @MainActor func changedMappingSupersedesInFlightRuntimeHealthCheck() async throws {
|
|
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
|
|
defer { try? FileManager.default.removeItem(at: runtime.directory) }
|
|
let first = runtime.directory.appendingPathComponent("first-plugin")
|
|
let second = runtime.directory.appendingPathComponent("second-plugin")
|
|
for binary in [first, second] {
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
}
|
|
let plugins = PluginCatalog(
|
|
store: PluginMappingFileStore(
|
|
fileURL: runtime.directory.appendingPathComponent("plugins.json")),
|
|
inspector: QuietPluginInspector())
|
|
try plugins.addPlugin(program: "tool", path: first.path)
|
|
var catalog = ConfigurationCatalog()
|
|
var fields = ActivationFixture.plainFields(remark: "Plugin")
|
|
fields.pluginProgram = "tool"
|
|
let id = try catalog.addServer(fields, to: nil)
|
|
let catalogFile = runtime.directory.appendingPathComponent("catalog.json")
|
|
try makeTestCatalogStore(fileURL: catalogFile).save(CatalogDocument(catalog: catalog))
|
|
let agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
|
let files = RuntimeFileStore(fileURL: runtime.contract)
|
|
let probe = ProxyRuntimeFixture.BlockingProbe()
|
|
let controller = makeHealthCheckSupersessionController(
|
|
runtime: runtime, catalogFile: catalogFile, plugins: plugins, agent: agent, probe: probe)
|
|
let started = XCTestExpectation(description: "old runtime entered health check")
|
|
probe.arm(started: started)
|
|
plugins.connect(
|
|
invalidateRuntime: { controller.pluginMappingsDidPublish() }, revalidate: {}, converge: {})
|
|
let oldActivation = Task { try await controller.activate(id) }
|
|
do {
|
|
try await requireTestPhase([started])
|
|
try plugins.updatePlugin(program: "tool", path: second.path)
|
|
probe.release()
|
|
_ = try await oldActivation.value
|
|
} catch {
|
|
probe.release()
|
|
oldActivation.cancel()
|
|
_ = await oldActivation.result
|
|
throw error
|
|
}
|
|
// A later successful deployment must not hide stale health-check side effects.
|
|
#expect(controller.state == .starting)
|
|
#expect(files.readFailure() == nil)
|
|
#expect(agent.unregisterCount == 0)
|
|
let outcome = await controller.catalogDidCommit(snapshot: catalog)
|
|
#expect(outcome == .converged(skippedServers: []))
|
|
#expect(files.loadDocument()?.servers.first?.plugin == second.path)
|
|
#expect(controller.state == .running)
|
|
}
|
|
|
|
}
|
|
|
|
struct PluginCatalogSecurityTests {
|
|
@Test(arguments: ["unchanged", "replace", "remove", "permissions"])
|
|
@MainActor func inspectionEvidenceFollowsCurrentMappingAndFile(change: String) async throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let binary = root.appendingPathComponent("tool")
|
|
try Data("#!/bin/sh\n".utf8).write(to: binary)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
let inspector = HeldPluginInspector()
|
|
let catalog = PluginCatalog(
|
|
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
|
|
inspector: inspector)
|
|
try catalog.addPlugin(program: "tool", path: binary.path)
|
|
let check = catalog.refreshSecurityFacts()
|
|
var started = inspector.started.makeAsyncIterator()
|
|
_ = await started.next()
|
|
if change == "replace" {
|
|
try Data("#!/bin/sh\n# replacement\n".utf8).write(to: binary, options: .atomic)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
|
} else if change == "permissions" {
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: binary.path)
|
|
} else if change == "remove" {
|
|
try catalog.removePlugin(program: "tool")
|
|
}
|
|
await inspector.release()
|
|
await check.value
|
|
if change == "unchanged" {
|
|
#expect(catalog.securityFacts["tool"]?.policy == .rejected)
|
|
#expect(catalog.resolve(program: "tool")?.path == binary.path)
|
|
} else {
|
|
#expect(catalog.securityFacts["tool"] == nil)
|
|
#expect(
|
|
catalog.resolve(program: "tool")?.path
|
|
== (["remove", "permissions"].contains(change) ? nil : binary.path))
|
|
}
|
|
}
|
|
|
|
@Test func symlinkTargetsMustBeExecutableFiles() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let directoryLink = root.appendingPathComponent("directory-link")
|
|
let missingLink = root.appendingPathComponent("missing-link")
|
|
try FileManager.default.createSymbolicLink(at: directoryLink, withDestinationURL: root)
|
|
try FileManager.default.createSymbolicLink(
|
|
at: missingLink, withDestinationURL: root.appendingPathComponent("missing"))
|
|
let snapshot = PluginCatalogSnapshot(
|
|
mappings: ["directory": directoryLink.path, "missing": missingLink.path])
|
|
#expect(snapshot.entry(for: "directory")?.availability == .notExecutable)
|
|
#expect(snapshot.entry(for: "missing")?.availability == .missing)
|
|
#expect(snapshot.resolve(program: "directory")?.path == nil)
|
|
#expect(snapshot.resolve(program: "missing")?.path == nil)
|
|
}
|
|
|
|
@Test func failedFileInspectionIsDistinctFromMissingFile() {
|
|
let snapshot = PluginCatalogSnapshot(
|
|
mappings: ["tool": "/unreadable/tool"],
|
|
managed: BundleManagedPluginProvider(fileManager: UnreadablePluginFileManager()))
|
|
#expect(snapshot.entry(for: "tool")?.availability == .unreadable)
|
|
#expect(snapshot.resolve(program: "tool")?.path == nil)
|
|
}
|
|
|
|
/// 用户来源条目在生产映射中恒无版本(managedInfo 为 nil),
|
|
/// 报告行内显示来源标签而不是版本号。
|
|
@Test func userDiagnosticFactsShowSourceLabelInsteadOfVersion() {
|
|
var snapshot = DiagnosticSnapshot()
|
|
snapshot.managedPlugins = [
|
|
DiagnosticPluginFacts(program: "v2ray-plugin", version: "", present: true, source: .user)
|
|
]
|
|
let report = DiagnosticReportBuilder.markdown(from: snapshot)
|
|
#expect(report.contains("v2ray-plugin user"))
|
|
}
|
|
|
|
}
|
|
|
|
private struct FailingMappingStore: PluginMappingStoring {
|
|
func load() throws -> PluginConfiguration { PluginConfiguration() }
|
|
func save(_ configuration: PluginConfiguration) throws { throw PluginMappingError.unreadable }
|
|
}
|
|
|
|
private actor HeldPluginInspector: PluginInspecting {
|
|
nonisolated let started: AsyncStream<Void>
|
|
private let signal: AsyncStream<Void>.Continuation
|
|
private var pending: CheckedContinuation<PluginSecurityFacts, Never>?
|
|
|
|
init() {
|
|
let stream = AsyncStream<Void>.makeStream()
|
|
started = stream.stream
|
|
signal = stream.continuation
|
|
}
|
|
|
|
func inspect(_ executable: URL) async -> PluginSecurityFacts {
|
|
await withCheckedContinuation { continuation in
|
|
pending = continuation
|
|
signal.yield(())
|
|
}
|
|
}
|
|
|
|
func release() {
|
|
pending?.resume(
|
|
returning: PluginSecurityFacts(
|
|
quarantine: .present, signature: .unsigned, policy: .rejected))
|
|
pending = nil
|
|
}
|
|
}
|
|
|
|
private final class UnreadablePluginFileManager: FileManager, @unchecked Sendable {
|
|
override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
|
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
|
}
|
|
}
|