import Foundation import Testing @testable import ShadowsocksX_NG2 struct PluginRecordTests { @Test(arguments: [ #""" {"userPlugins":[{"id":"00000000-0000-0000-0000-000000000001","names":["tool"], "path":"/missing/tool","arguments":[],"plugin_mode":"udp_only"}],"managedArguments":{}} """#, #""" {"userPlugins":[],"managedArguments":{}, "managedModes":{"v2ray-plugin":"udp_only"}} """#, ]) func unknownTransportModeBlocksReadingWithoutRewriting(configurationJSON: String) 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let original = Data( "{\"version\":2,\"configuration\":\(configurationJSON)}".utf8) try original.write(to: store.fileURL) #expect(throws: PluginMappingError.unreadable) { try store.load() } #expect(try Data(contentsOf: store.fileURL) == original) } @Test func oldV2RecordsDefaultToTCPWithoutRewriting() 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let original = Data( #""" {"version":2,"configuration":{"userPlugins":[ {"id":"00000000-0000-0000-0000-000000000001","names":["tool"], "path":"/missing/tool","arguments":[]}],"managedArguments":{}}} """# .utf8) try original.write(to: store.fileURL) let configuration = try store.load() #expect(configuration.userPlugins.first?.mode == .tcpOnly) #expect( PluginCatalogSnapshot(configuration: configuration).managedRecords["v2ray-plugin"]?.mode == .tcpOnly) #expect(try Data(contentsOf: store.fileURL) == original) try store.save(configuration) #expect(try store.load() == configuration) } @Test @MainActor func managedModePersistsAcrossAliasesAndArgumentClearing() 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let managed = BundleManagedPluginProvider(bundleURL: root) let initial = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) let originalArguments = try #require(initial.resolve(program: "v2ray-plugin")).arguments try Data( #""" {"version":2,"configuration":{"userPlugins":[], "managedArguments":{"v2ray-plugin":["--verbose"]}, "managedModes":{"v2ray-plugin":"tcp_and_udp"}}} """# .utf8 ).write(to: store.fileURL) let catalog = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) #expect(catalog.resolve(program: "v2ray-plugin-v1")?.mode == .tcpAndUDP) try catalog.commit([.managedArguments(program: "v2ray-plugin", arguments: [])]) let restored = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) #expect(restored.resolve(program: "v2ray-plugin")?.mode == .tcpAndUDP) #expect(restored.resolve(program: "v2ray-plugin")?.arguments == originalArguments) try restored.commit([.save(UserPluginRecord(names: ["v2ray-plugin"], path: binary.path))]) #expect(restored.resolve(program: "v2ray-plugin")?.mode == .tcpOnly) #expect(restored.resolve(program: "v2ray-plugin-v1")?.mode == .tcpAndUDP) } @Test @MainActor func nameConflictAndInvalidArgumentsDoNotPublish() 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let catalog = PluginCatalog(store: store, inspector: QuietPluginInspector()) let first = UserPluginRecord(names: ["tool", "tool-v2"], path: binary.path) try catalog.commit([.save(first)]) let bytes = try Data(contentsOf: store.fileURL) let generation = catalog.catalogSnapshot().generation #expect(throws: PluginMappingError.nameExists) { try catalog.commit([.save(UserPluginRecord(names: ["tool-v2"], path: binary.path))]) } #expect(throws: PluginMappingError.invalidArguments) { try catalog.commit([.managedArguments(program: "v2ray-plugin", arguments: ["bad\0value"])]) } #expect(try Data(contentsOf: store.fileURL) == bytes) #expect(catalog.catalogSnapshot().generation == generation) #expect(catalog.userRecords == [first]) var renamed = first renamed.names = ["tool-v2"] try catalog.commit([.save(renamed)]) #expect(catalog.resolve(program: "tool") == nil) #expect(catalog.userRecords.first?.id == first.id) #expect(catalog.resolve(program: "tool-v2")?.path == binary.path) } @Test func legacyMappingsMigrateIndependentlyAndReadDoesNotRewriteFile() 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let original = Data( #"{"version":1,"mappings":{"tool":"/missing/tool","tool-v2":"/missing/tool"}}"#.utf8) try original.write(to: store.fileURL) let configuration = try store.load() #expect(configuration.userPlugins.count == 2) #expect(Set(configuration.userPlugins.map(\.id)).count == 2) #expect(configuration.userPlugins.map(\.names) == [["tool"], ["tool-v2"]]) #expect( configuration.userPlugins.allSatisfy { $0.arguments.isEmpty && $0.path == "/missing/tool" && $0.mode == .tcpOnly }) #expect(try Data(contentsOf: store.fileURL) == original) try store.save(configuration) #expect(try store.load() == configuration) let json = try #require( JSONSerialization.jsonObject(with: Data(contentsOf: store.fileURL)) as? [String: Any]) #expect(json["version"] as? Int == 2) } @Test(arguments: [PluginTransportMode.tcpOnly, .tcpAndUDP]) @MainActor func activationWritesLocalArgumentsWithoutChangingServerOptions( mode: PluginTransportMode ) 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 plugins = PluginCatalog( store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")), inspector: QuietPluginInspector()) try plugins.commit([ .save( UserPluginRecord( names: ["tool", "tool-v2"], path: binary.path, arguments: ["--threads", "4", "--log-file", "/tmp/My Log.txt", ""], mode: mode)) ]) var catalog = ConfigurationCatalog() let leaf = try ActivationFixture.addPlainServer( "Plugin", in: &catalog) var fields = try ActivationFixture.serverFields(of: leaf, in: catalog) fields.pluginProgram = "tool-v2" fields.pluginOptions = "options" fields.pluginOptions = "tls;host=example.com" try catalog.updateServer(leaf, with: fields) var machine = ActivationStateMachine() let runtime = try machine.activate( leaf, in: catalog, plugins: plugins, options: RuntimeDocumentOptions(listen: ActivationFixture.listen)) let json = try #require( JSONSerialization.jsonObject(with: runtime.document.jsonData()) as? [String: Any]) let server = try #require((json["servers"] as? [[String: Any]])?.first) #expect( server["plugin_args"] as? [String] == ["--threads", "4", "--log-file", "/tmp/My Log.txt", ""]) #expect(server["plugin_mode"] as? String == (mode == .tcpAndUDP ? "tcp_and_udp" : nil)) #expect(server["plugin"] as? String == binary.path) #expect(server["plugin_opts"] as? String == "tls;host=example.com") #expect(try ActivationFixture.serverFields(of: leaf, in: catalog).pluginProgram == "tool-v2") } @Test(arguments: ["v2ray-plugin", "v2ray-plugin-v1"], [false, true]) @MainActor func activationAppliesAccessLogDefaultOnlyToManagedPlugins( program: String, overrideManaged: Bool ) 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 plugins = PluginCatalog( store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")), managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector()) try plugins.commit([.managedArguments(program: "v2ray-plugin", arguments: ["--verbose"])]) if overrideManaged { try plugins.commit([ .save(UserPluginRecord(names: [program], path: binary.path, arguments: ["--user-option"])) ]) } var catalog = ConfigurationCatalog() let leaf = try ActivationFixture.addPlainServer("Plugin", in: &catalog) var fields = try ActivationFixture.serverFields(of: leaf, in: catalog) fields.pluginProgram = program fields.pluginOptions = "tls;host=example.com" try catalog.updateServer(leaf, with: fields) var machine = ActivationStateMachine() let runtime = try machine.activate( leaf, in: catalog, plugins: plugins, options: RuntimeDocumentOptions(listen: ActivationFixture.listen)) let json = try #require( JSONSerialization.jsonObject(with: runtime.document.jsonData()) as? [String: Any]) let server = try #require((json["servers"] as? [[String: Any]])?.first) #expect( server["plugin_args"] as? [String] == (overrideManaged ? ["--user-option"] : ["-disableAccessLog", "--verbose"])) #expect(server["plugin"] as? String == binary.path) #expect(server["plugin_opts"] as? String == "tls;host=example.com") } @Test @MainActor func aliasesShareArgumentsAndOnlyExplicitNamesOverrideManaged() 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let catalog = PluginCatalog( store: store, managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector()) let originalAlias = try #require(catalog.resolve(program: "v2ray-plugin-v1")) let record = UserPluginRecord( names: ["v2ray-plugin", "custom-v2"], path: binary.path, arguments: ["--config", "/Users/me/My Config.json", "", "--verbose"]) try catalog.commit([.save(record)]) let snapshot = catalog.catalogSnapshot() #expect(snapshot.resolve(program: "custom-v2")?.arguments == record.arguments) #expect(snapshot.resolve(program: "v2ray-plugin") == snapshot.resolve(program: "custom-v2")) #expect(snapshot.entry(for: "v2ray-plugin-v1")?.source == .managed) #expect(snapshot.resolve(program: "v2ray-plugin-v1") == originalAlias) let restored = PluginCatalog(store: store, inspector: QuietPluginInspector()) #expect(restored.userRecords == [record]) } } extension PluginRecordTests { @Test @MainActor func cloakOverrideOnlyReplacesExplicitAlias() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let client = root.appendingPathComponent("Contents/Helpers/Plugins/ck-client") try FileManager.default.createDirectory( at: client.deletingLastPathComponent(), withIntermediateDirectories: true) try Data("#!/bin/sh\n".utf8).write(to: client) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) let catalog = PluginCatalog( store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")), managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector()) try catalog.commit([.managedArguments(program: "ck-client", arguments: ["-v"])]) let userClient = root.appendingPathComponent("user-client") try Data("#!/bin/sh\n".utf8).write(to: userClient) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: userClient.path) let user = UserPluginRecord( names: ["cloak"], path: userClient.path, arguments: ["-c", "local.json"]) try catalog.commit([.save(user)]) #expect(catalog.resolve(program: "cloak")?.arguments == user.arguments) #expect(catalog.resolve(program: "cloak-v2")?.arguments == ["-v"]) #expect(catalog.catalogSnapshot().entry(for: "cloak")?.source == .user) #expect(catalog.catalogSnapshot().entry(for: "cloak-v2")?.source == .managed) try FileManager.default.removeItem(at: userClient) #expect(catalog.resolve(program: "cloak") == nil) #expect(catalog.resolve(program: "cloak-v2")?.path == client.path) #expect(catalog.catalogSnapshot().entry(for: "cloak")?.source == .user) try catalog.commit([.remove(user.id)]) #expect(catalog.resolve(program: "cloak")?.arguments == ["-v"]) } } extension PluginRecordTests { @Test @MainActor func sharingKeepsReferenceAndOptionsWithoutLocalArguments() 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 plugins = PluginCatalog( store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")), inspector: QuietPluginInspector()) try plugins.commit([ .save( UserPluginRecord( names: ["tool", "tool-v2"], path: binary.path, arguments: ["--local-argument-sentinel"], mode: .tcpAndUDP)) ]) let workflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: makeTestCatalogStore(fileURL: root.appendingPathComponent("catalog.json")), runtime: FakeCatalogRuntime()), plugins: plugins) let group = try await workflow.createGroup(named: "Share", into: nil) let id = try await workflow.createServer( ServerEditDraft( address: "example.com", port: 8388, encryptionMethod: "aes-256-gcm", password: "pw", remark: "Plugin", plugin: .named(program: "tool-v2"), pluginOptions: "tls;host=example.com"), into: group) let draft = try workflow.configurationGroupExportDraft(for: group) let json = try #require(JSONSerialization.jsonObject(with: draft.data) as? [String: Any]) let server = try #require((json["servers"] as? [[String: Any]])?.first) #expect(server["plugin"] as? String == "tool-v2") #expect(server["plugin_opts"] as? String == "tls;host=example.com") #expect(server["plugin_args"] == nil) #expect(server["plugin_mode"] == nil) let uri = try workflow.shareURI(for: id) #expect(uri.contains("tool-v2")) #expect(!uri.contains("local-argument-sentinel")) #expect(!uri.contains("tcp_and_udp")) let exportedText = try #require(String(bytes: draft.data, encoding: .utf8)) #expect(!exportedText.contains(binary.path)) } @Test @MainActor func managedArgumentsRemainWithUncoveredAliasesAndCanBeCleared() 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 store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) let managed = BundleManagedPluginProvider(bundleURL: root) let catalog = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) let originalArguments = try #require(catalog.resolve(program: "v2ray-plugin-v1")).arguments try catalog.commit([.managedArguments(program: "v2ray-plugin", arguments: ["--verbose"])]) let configuredAlias = try #require(catalog.resolve(program: "v2ray-plugin-v1")) #expect(configuredAlias.arguments == originalArguments + ["--verbose"]) let user = UserPluginRecord( names: ["v2ray-plugin"], path: binary.path, arguments: ["--threads", "4"]) try catalog.commit([.save(user)]) #expect(catalog.resolve(program: "v2ray-plugin")?.arguments == ["--threads", "4"]) let restored = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) #expect(restored.resolve(program: "v2ray-plugin-v1") == configuredAlias) try restored.commit([.managedArguments(program: "v2ray-plugin", arguments: [])]) #expect(restored.resolve(program: "v2ray-plugin-v1")?.arguments == originalArguments) #expect(restored.resolve(program: "v2ray-plugin")?.arguments == ["--threads", "4"]) try restored.commit([.remove(user.id)]) #expect(restored.resolve(program: "v2ray-plugin")?.arguments == originalArguments) } }