Files

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)
}
}