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.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 private let signal: AsyncStream.Continuation private var pending: CheckedContinuation? init() { let stream = AsyncStream.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) } }