From 346af7fdae725bb9610187f784a06f00e061d2c0 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 17 Oct 2026 15:10:34 +0800 Subject: [PATCH] refactor(activation): collapse leaf validate-derive into one preparation Each activation leaf was validated and then derived, resolving keychain secrets and plugin facts twice and folding derivation failures back into skips. Prepare once and hand the resolved facts to document assembly so the catalog tree and activation share one judgment set without a dead fold-back path. --- .../CatalogWorkflowModels.swift | 5 +- .../DiagnosticsWorkflow.swift | 2 +- .../Activation/ActivationStateMachine.swift | 102 ++++----------- .../Activation/EncryptionMethodCatalog.swift | 19 +++ .../Domain/Activation/LeafPreparation.swift | 95 ++++++++++++++ .../Domain/Activation/ServerValidation.swift | 105 --------------- .../Plugin/ManagedPluginProviding.swift | 4 - .../Activation/LeafPreparationTests.swift | 121 ++++++++++++++++++ .../Activation/ServerValidationTests.swift | 75 ----------- .../BundleManagedPluginProviderTests.swift | 28 ++-- .../Tests/Plugin/PluginCatalogTests.swift | 28 ++-- .../Plugin/PluginMappingCommitTests.swift | 2 +- 12 files changed, 289 insertions(+), 297 deletions(-) create mode 100644 ShadowsocksX-NG2/Domain/Activation/EncryptionMethodCatalog.swift create mode 100644 ShadowsocksX-NG2/Domain/Activation/LeafPreparation.swift delete mode 100644 ShadowsocksX-NG2/Domain/Activation/ServerValidation.swift create mode 100644 ShadowsocksX-NG2/Tests/Activation/LeafPreparationTests.swift delete mode 100644 ShadowsocksX-NG2/Tests/Activation/ServerValidationTests.swift diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift index 80cb3bc2..cc727ddf 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift @@ -175,8 +175,6 @@ extension CatalogTreeSnapshot { invalidDescendantCount: children.reduce(0) { $0 + $1.subtreeInvalidServerCount }, children: children) case .server(let fields): - let validation = ServerValidation.evaluate( - fields, credentials: credentials, plugins: plugins) return CatalogTreeNode( id: id, name: entry.displayName, @@ -185,7 +183,8 @@ extension CatalogTreeSnapshot { parentID: parentID, createdAt: entry.createdAt, updatedAt: entry.updatedAt, - invalidReasons: validation.issues, + invalidReasons: LeafPreparation.invalidReasons( + fields, credentials: credentials, plugins: plugins), childCount: 0, subtreeNodeCount: 0, invalidDescendantCount: 0, diff --git a/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift b/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift index 65603f76..2fc531b9 100644 --- a/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift @@ -261,7 +261,7 @@ final class DiagnosticsWorkflow: ObservableObject { DiagnosticPluginFacts( program: info.program, version: info.release, - present: provider.executablePath(forProgram: info.program) != nil) + present: provider.resolve(program: info.program) != nil) } } diff --git a/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift b/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift index d086a6cd..9cfe2a0a 100644 --- a/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift +++ b/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift @@ -111,8 +111,6 @@ struct ActivationStateMachine: Equatable, Sendable { } else { return .failure(.invalidLeaf(node: skipped.id, reason: skipped.reason)) } - case .failed(let failure): - return .failure(failure) } } guard !servers.isEmpty else { return .failure(.targetExpandsToNothing(target)) } @@ -132,11 +130,11 @@ struct ActivationStateMachine: Equatable, Sendable { } /// 单叶派生结果。`.skippedCandidate` 携带点名原因,分组语义下可跳过, - /// 单服务器目标由调用方转成整体拒绝。 - private enum LeafDerival { + /// 单服务器目标由调用方转成整体拒绝。预检已消化全部已知阻塞原因, + /// 不再有派生期折回。 + private enum LeafDerivation { case server(SslocalServerDocument) case skippedCandidate(SkippedServer) - case failed(ActivationFailure) } private func deriveLeaf( @@ -144,23 +142,31 @@ struct ActivationStateMachine: Equatable, Sendable { in catalog: ConfigurationCatalog, credentials: CredentialStoring, plugins: PluginExecutableResolving - ) -> LeafDerival { + ) -> LeafDerivation { guard let entry = catalog.entry(for: leafID), case .server(let fields) = entry.kind else { preconditionFailure("展开结果只含服务器叶子") } - let validation = ServerValidation.evaluate(fields, credentials: credentials, plugins: plugins) - if let issue = validation.issues.first { + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: plugins) + if let issue = preparation.issues.first { return .skippedCandidate(SkippedServer(id: leafID, reason: issue)) } - switch derivedServer(leafID, in: catalog, credentials: credentials, plugins: plugins) { - case .success(let server): - return .server(server) - case .failure(let failure): - if case .invalidLeaf(let node, let reason) = failure { - return .skippedCandidate(SkippedServer(id: node, reason: reason)) - } - return .failed(failure) + // 预检通过 ⇒ 密码已解析;插件事实与选项引用同理由预检给出。 + guard let password = preparation.password else { + preconditionFailure("预检通过则密码必已解析") } + return .server( + SslocalServerDocument( + id: leafID.rawValue, + server: fields.address.trimmingCharacters(in: .whitespacesAndNewlines), + serverPort: fields.port, + password: password, + method: fields.encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines), + plugin: preparation.plugin?.path, + pluginOpts: preparation.pluginOptions, + pluginArgs: preparation.plugin?.arguments.isEmpty == true + ? nil : preparation.plugin?.arguments, + pluginMode: preparation.plugin?.mode == .tcpOnly ? nil : preparation.plugin?.mode)) } /// 按显式子序深度优先收集全部服务器叶子;有效性在派生阶段统一预检。 @@ -185,68 +191,4 @@ struct ActivationStateMachine: Equatable, Sendable { } } } - - /// 校验单个有效候选叶子并解析为上游条目;凭据在派生时从凭据存储解析。 - private func derivedServer( - _ leafID: NodeID, - in catalog: ConfigurationCatalog, - credentials: CredentialStoring, - plugins: PluginExecutableResolving - ) -> Result { - guard let entry = catalog.entry(for: leafID), case .server(let fields) = entry.kind else { - preconditionFailure("展开结果只含服务器叶子") - } - let password: String - switch resolveCredential(fields.passwordRef, of: leafID, credentials: credentials) { - case .success(let resolved): - password = resolved - case .failure(let failure): - return .failure(failure) - } - var pluginPath: String? - var pluginOpts: String? - var pluginArgs: [String]? - var pluginMode: PluginTransportMode? - if let program = fields.pluginProgram { - guard let resolved = plugins.resolve(program: program) else { - return .failure(.invalidLeaf(node: leafID, reason: .pluginNotProvided(program: program))) - } - pluginMode = resolved.mode == .tcpOnly ? nil : resolved.mode - pluginPath = resolved.path - pluginArgs = resolved.arguments.isEmpty ? nil : resolved.arguments - if let optionsRef = fields.pluginOptionsRef { - switch resolveCredential(optionsRef, of: leafID, credentials: credentials) { - case .success(let resolved): - pluginOpts = resolved - case .failure(let failure): - return .failure(failure) - } - } - } - return .success( - SslocalServerDocument( - id: leafID.rawValue, - server: fields.address.trimmingCharacters(in: .whitespacesAndNewlines), - serverPort: fields.port, - password: password, - method: fields.encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines), - plugin: pluginPath, - pluginOpts: pluginOpts, pluginArgs: pluginArgs, pluginMode: pluginMode)) - } - - /// 凭据解析:无条目与读取失败分别点名(「凭据不可解析 → 停止并清理」)。 - private func resolveCredential( - _ reference: CredentialReference, - of leafID: NodeID, - credentials: CredentialStoring - ) -> Result { - do { - guard let secret = try credentials.secret(for: reference), !secret.isEmpty else { - return .failure(.invalidLeaf(node: leafID, reason: .credentialUnresolved(reference))) - } - return .success(secret) - } catch { - return .failure(.invalidLeaf(node: leafID, reason: .credentialReadFailed(reference))) - } - } } diff --git a/ShadowsocksX-NG2/Domain/Activation/EncryptionMethodCatalog.swift b/ShadowsocksX-NG2/Domain/Activation/EncryptionMethodCatalog.swift new file mode 100644 index 00000000..e39ef19e --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Activation/EncryptionMethodCatalog.swift @@ -0,0 +1,19 @@ +import Foundation + +/// 加密方式能力目录(与当前随 app 打包的 sslocal 版本保持一致)。 +/// 目录值是本地可验证的能力事实,不代表远端服务器一定可达。 +enum EncryptionMethodCatalog { + static let supported: Set = [ + "aes-128-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305", + "none", + ] + + static func isSupported(_ method: String) -> Bool { + supported.contains(method) + } +} diff --git a/ShadowsocksX-NG2/Domain/Activation/LeafPreparation.swift b/ShadowsocksX-NG2/Domain/Activation/LeafPreparation.swift new file mode 100644 index 00000000..36c13a3c --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Activation/LeafPreparation.swift @@ -0,0 +1,95 @@ +import Foundation + +/// 叶子预检:一次完成激活阻塞判定与凭据/插件解析。插件参数保持 opaque,不 +/// 在此判断其语法或远端握手;空 issues 只表示没有已知的本地激活阻塞原因。 +/// 已解析事实供运行时文档装配复用,目录树只取 `invalidReasons`。 +/// +/// 不变量:`issues` 为空 ⇒ `password` 非空;`pluginProgram` 存在且无 +/// `pluginNotProvided` ⇒ `plugin` 非空。 +struct LeafPreparation: Equatable, Sendable { + let issues: [LeafInvalidationReason] + let password: String? + let plugin: ResolvedPlugin? + let pluginOptions: String? + + /// 运行时装配入口:一次解析并给出全部阻塞原因与已解析事实。 + static func prepare( + _ fields: ServerFields, + credentials: CredentialStoring, + plugins: PluginExecutableResolving + ) -> LeafPreparation { + var issues: [LeafInvalidationReason] = [] + appendAddressIssue(fields.address, to: &issues) + appendPortIssue(fields.port, to: &issues) + appendMethodIssue(fields.encryptionMethod, to: &issues) + + let password = resolveCredential(fields.passwordRef, credentials: credentials, issues: &issues) + var plugin: ResolvedPlugin? + var pluginOptions: String? + if let program = fields.pluginProgram { + if let resolved = plugins.resolve(program: program) { + plugin = resolved + } else { + issues.append(.pluginNotProvided(program: program)) + } + if let optionsRef = fields.pluginOptionsRef { + pluginOptions = resolveCredential(optionsRef, credentials: credentials, issues: &issues) + } + } + return LeafPreparation( + issues: issues, password: password, plugin: plugin, pluginOptions: pluginOptions) + } + + /// validation-only 入口:目录树等只读呈现取全部阻塞原因,不组装运行时文档。 + static func invalidReasons( + _ fields: ServerFields, + credentials: CredentialStoring, + plugins: PluginExecutableResolving + ) -> [LeafInvalidationReason] { + prepare(fields, credentials: credentials, plugins: plugins).issues + } + + /// 凭据解析:无条目与读取失败分别点名(「凭据不可解析 → 停止并清理」)。 + /// 成功返回秘密;失败写入 issues 并返回 nil。 + private static func resolveCredential( + _ reference: CredentialReference, + credentials: CredentialStoring, + issues: inout [LeafInvalidationReason] + ) -> String? { + do { + guard let secret = try credentials.secret(for: reference), !secret.isEmpty else { + issues.append(.credentialUnresolved(reference)) + return nil + } + return secret + } catch { + issues.append(.credentialReadFailed(reference)) + return nil + } + } + + private static func appendAddressIssue( + _ address: String, to issues: inout [LeafInvalidationReason] + ) { + if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append(.invalidAddress) + } + } + + private static func appendPortIssue( + _ port: Int, to issues: inout [LeafInvalidationReason] + ) { + if !(1...65_535).contains(port) { issues.append(.invalidPort(port)) } + } + + private static func appendMethodIssue( + _ method: String, to issues: inout [LeafInvalidationReason] + ) { + let trimmed = method.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + issues.append(.missingEncryptionMethod) + } else if !EncryptionMethodCatalog.isSupported(trimmed) { + issues.append(.unsupportedEncryptionMethod(trimmed)) + } + } +} diff --git a/ShadowsocksX-NG2/Domain/Activation/ServerValidation.swift b/ShadowsocksX-NG2/Domain/Activation/ServerValidation.swift deleted file mode 100644 index 2ac6197c..00000000 --- a/ShadowsocksX-NG2/Domain/Activation/ServerValidation.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -/// 加密方式能力目录(与当前随 app 打包的 sslocal 版本保持一致)。 -/// 目录值是本地可验证的能力事实,不代表远端服务器一定可达。 -enum EncryptionMethodCatalog { - static let supported: Set = [ - "aes-128-gcm", - "aes-256-gcm", - "chacha20-ietf-poly1305", - "2022-blake3-aes-128-gcm", - "2022-blake3-aes-256-gcm", - "2022-blake3-chacha20-poly1305", - "none", - ] - - static func isSupported(_ method: String) -> Bool { - supported.contains(method) - } -} - -/// 服务器配置的 app 可知校验结果。插件参数保持 opaque,不在此处判断其语法或 -/// 远端握手;空 issues 只表示没有已知的本地激活阻塞原因。 -struct ServerValidation: Equatable, Sendable { - let issues: [LeafInvalidationReason] - - var isValid: Bool { issues.isEmpty } - - static func evaluate( - _ fields: ServerFields, - credentials: CredentialStoring, - plugins: PluginExecutableResolving - ) -> ServerValidation { - var issues: [LeafInvalidationReason] = [] - appendAddressIssue(fields.address, to: &issues) - appendPortIssue(fields.port, to: &issues) - appendMethodIssue(fields.encryptionMethod, to: &issues) - appendCredentialIssue(fields.passwordRef, credentials: credentials, to: &issues) - appendPluginIssues(fields, credentials: credentials, plugins: plugins, to: &issues) - return ServerValidation(issues: issues) - } - - private static func appendAddressIssue( - _ address: String, to issues: inout [LeafInvalidationReason] - ) { - if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - issues.append(.invalidAddress) - } - } - - private static func appendPortIssue( - _ port: Int, to issues: inout [LeafInvalidationReason] - ) { - if !(1...65_535).contains(port) { issues.append(.invalidPort(port)) } - } - - private static func appendMethodIssue( - _ method: String, to issues: inout [LeafInvalidationReason] - ) { - let trimmed = method.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - issues.append(.missingEncryptionMethod) - } else if !EncryptionMethodCatalog.isSupported(trimmed) { - issues.append(.unsupportedEncryptionMethod(trimmed)) - } - } - - private static func appendCredentialIssue( - _ reference: CredentialReference, - credentials: CredentialStoring, - to issues: inout [LeafInvalidationReason] - ) { - if case .failure(let issue) = resolveCredential(reference, credentials: credentials) { - issues.append(issue) - } - } - - private static func appendPluginIssues( - _ fields: ServerFields, - credentials: CredentialStoring, - plugins: PluginExecutableResolving, - to issues: inout [LeafInvalidationReason] - ) { - guard let program = fields.pluginProgram else { return } - if plugins.executablePath(forProgram: program) == nil { - issues.append(.pluginNotProvided(program: program)) - } - if let optionsRef = fields.pluginOptionsRef { - appendCredentialIssue(optionsRef, credentials: credentials, to: &issues) - } - } - - private static func resolveCredential( - _ reference: CredentialReference, - credentials: CredentialStoring - ) -> Result { - do { - guard let secret = try credentials.secret(for: reference), !secret.isEmpty else { - return .failure(.credentialUnresolved(reference)) - } - return .success(secret) - } catch { - return .failure(.credentialReadFailed(reference)) - } - } -} diff --git a/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift b/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift index 5d1b6bf7..825a47a4 100644 --- a/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift +++ b/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift @@ -14,10 +14,6 @@ protocol PluginExecutableResolving { } extension PluginExecutableResolving { - func executablePath(forProgram program: String) -> String? { - resolve(program: program)?.path - } - func catalogSnapshot() -> PluginCatalogSnapshot { PluginCatalogSnapshot( entries: ManagedPluginCatalog.plugins.flatMap { info in diff --git a/ShadowsocksX-NG2/Tests/Activation/LeafPreparationTests.swift b/ShadowsocksX-NG2/Tests/Activation/LeafPreparationTests.swift new file mode 100644 index 00000000..2156d5bf --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Activation/LeafPreparationTests.swift @@ -0,0 +1,121 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 叶子预检缝:一次完成激活阻塞判定与凭据/插件解析,产出供目录树呈现与 +/// 运行时装配共用的事实。文档装配不在本缝。 +final class LeafPreparationTests: XCTestCase { + func testSupportedServerWithOpaquePluginOptionsIsReady() throws { + let passwordRef = CredentialReference(rawValue: "password") + let optionsRef = CredentialReference(rawValue: "options") + let credentials = try ActivationFixture.makeCredentials([ + passwordRef: "password", + optionsRef: "plugin-option-that-the-app-does-not-parse", + ]) + let fields = ServerFields( + address: "203.0.113.7", + port: 8388, + encryptionMethod: "aes-256-gcm", + passwordRef: passwordRef, + pluginProgram: "v2ray-plugin", + pluginOptionsRef: optionsRef) + + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual(preparation.issues, []) + XCTAssertEqual(preparation.password, "password") + XCTAssertEqual(preparation.plugin?.path, "/bundle/Helpers/Plugins/v2ray-plugin") + XCTAssertEqual(preparation.pluginOptions, "plugin-option-that-the-app-does-not-parse") + } + + func testUnsupportedEncryptionMethodIsAnInvalidActivationCandidate() throws { + let passwordRef = CredentialReference(rawValue: "password") + let credentials = try ActivationFixture.makeCredentials([passwordRef: "password"]) + let fields = ServerFields( + address: "203.0.113.7", + port: 8388, + encryptionMethod: "future-cipher", + passwordRef: passwordRef) + + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual(preparation.issues, [.unsupportedEncryptionMethod("future-cipher")]) + } + + func testMissingPluginExecutableIsInvalidWithoutInspectingOptions() throws { + let passwordRef = CredentialReference(rawValue: "password") + let optionsRef = CredentialReference(rawValue: "options") + let credentials = try ActivationFixture.makeCredentials([ + passwordRef: "password", + optionsRef: "opaque-options", + ]) + let fields = ServerFields( + address: "203.0.113.7", + port: 8388, + encryptionMethod: "aes-256-gcm", + passwordRef: passwordRef, + pluginProgram: "unknown-plugin", + pluginOptionsRef: optionsRef) + + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: NoManagedPluginProvider()) + + XCTAssertEqual( + preparation.issues, + [.pluginNotProvided(program: "unknown-plugin")]) + } + + func testMissingPasswordIsInvalid() throws { + let passwordRef = CredentialReference(rawValue: "password") + let credentials = try ActivationFixture.makeCredentials([passwordRef: ""]) + let fields = ActivationFixture.plainFields(remark: "server", passwordRef: passwordRef) + + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual(preparation.issues, [.credentialUnresolved(passwordRef)]) + } + + func testFieldAndCredentialIssuesAreAllCollected() throws { + let passwordRef = CredentialReference(rawValue: "password") + let credentials = try ActivationFixture.makeCredentials([passwordRef: ""]) + let fields = ServerFields( + address: " ", + port: 0, + encryptionMethod: "future-cipher", + passwordRef: passwordRef) + + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual( + preparation.issues, + [ + .invalidAddress, + .invalidPort(0), + .unsupportedEncryptionMethod("future-cipher"), + .credentialUnresolved(passwordRef), + ]) + XCTAssertNil(preparation.password) + XCTAssertNil(preparation.plugin) + XCTAssertNil(preparation.pluginOptions) + } + + func testResolvedPluginFactsPassThroughForAssembly() throws { + let passwordRef = CredentialReference(rawValue: "password") + let credentials = try ActivationFixture.makeCredentials([passwordRef: "SECRET"]) + var fields = ActivationFixture.plainFields(remark: "带插件", passwordRef: passwordRef) + fields.pluginProgram = "v2ray-plugin" + + let preparation = LeafPreparation.prepare( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual(preparation.issues, []) + XCTAssertEqual(preparation.password, "SECRET") + XCTAssertEqual(preparation.plugin?.path, "/bundle/Helpers/Plugins/v2ray-plugin") + XCTAssertEqual(preparation.plugin?.arguments, []) + XCTAssertNil(preparation.pluginOptions, "无插件选项引用时不解析选项") + } +} diff --git a/ShadowsocksX-NG2/Tests/Activation/ServerValidationTests.swift b/ShadowsocksX-NG2/Tests/Activation/ServerValidationTests.swift deleted file mode 100644 index ff8b5ba7..00000000 --- a/ShadowsocksX-NG2/Tests/Activation/ServerValidationTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import XCTest - -@testable import ShadowsocksX_NG2 - -final class ServerValidationTests: XCTestCase { - func testSupportedServerWithOpaquePluginOptionsIsValid() throws { - let passwordRef = CredentialReference(rawValue: "password") - let optionsRef = CredentialReference(rawValue: "options") - let credentials = try ActivationFixture.makeCredentials([ - passwordRef: "password", - optionsRef: "plugin-option-that-the-app-does-not-parse", - ]) - let fields = ServerFields( - address: "203.0.113.7", - port: 8388, - encryptionMethod: "aes-256-gcm", - passwordRef: passwordRef, - pluginProgram: "v2ray-plugin", - pluginOptionsRef: optionsRef) - - let validation = ServerValidation.evaluate( - fields, credentials: credentials, plugins: ActivationFixture.plugins) - - XCTAssertTrue(validation.isValid) - } - - func testUnsupportedEncryptionMethodIsAnInvalidActivationCandidate() throws { - let passwordRef = CredentialReference(rawValue: "password") - let credentials = try ActivationFixture.makeCredentials([passwordRef: "password"]) - let fields = ServerFields( - address: "203.0.113.7", - port: 8388, - encryptionMethod: "future-cipher", - passwordRef: passwordRef) - - let validation = ServerValidation.evaluate( - fields, credentials: credentials, plugins: ActivationFixture.plugins) - - XCTAssertEqual(validation.issues, [.unsupportedEncryptionMethod("future-cipher")]) - } - - func testMissingPluginExecutableIsInvalidWithoutInspectingOptions() throws { - let passwordRef = CredentialReference(rawValue: "password") - let optionsRef = CredentialReference(rawValue: "options") - let credentials = try ActivationFixture.makeCredentials([ - passwordRef: "password", - optionsRef: "opaque-options", - ]) - let fields = ServerFields( - address: "203.0.113.7", - port: 8388, - encryptionMethod: "aes-256-gcm", - passwordRef: passwordRef, - pluginProgram: "unknown-plugin", - pluginOptionsRef: optionsRef) - - let validation = ServerValidation.evaluate( - fields, credentials: credentials, plugins: NoManagedPluginProvider()) - - XCTAssertEqual( - validation.issues, - [.pluginNotProvided(program: "unknown-plugin")]) - } - - func testMissingPasswordIsInvalid() throws { - let passwordRef = CredentialReference(rawValue: "password") - let credentials = try ActivationFixture.makeCredentials([passwordRef: ""]) - let fields = ActivationFixture.plainFields(remark: "server", passwordRef: passwordRef) - - let validation = ServerValidation.evaluate( - fields, credentials: credentials, plugins: ActivationFixture.plugins) - - XCTAssertEqual(validation.issues, [.credentialUnresolved(passwordRef)]) - } -} diff --git a/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift b/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift index a2c684ff..751c192d 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/BundleManagedPluginProviderTests.swift @@ -43,9 +43,9 @@ final class BundleManagedPluginProviderTests: XCTestCase { func testMajorVersionAliasResolvesToSameBundledExecutable() throws { try writePluginBinary(permissions: 0o755) XCTAssertEqual( - makeProvider().executablePath(forProgram: "v2ray-plugin-v1"), - makeProvider().executablePath(forProgram: "v2ray-plugin")) - XCTAssertNotNil(makeProvider().executablePath(forProgram: "v2ray-plugin-v1")) + makeProvider().resolve(program: "v2ray-plugin-v1")?.path, + makeProvider().resolve(program: "v2ray-plugin")?.path) + XCTAssertNotNil(makeProvider().resolve(program: "v2ray-plugin-v1")?.path) } func testCloakAliasesUseClientBinaryAndPreserveOptions() throws { @@ -78,28 +78,28 @@ final class BundleManagedPluginProviderTests: XCTestCase { func testReturnsBundleAbsolutePathForProvidedManagedProgram() throws { try writePluginBinary(permissions: 0o755) - let path = try XCTUnwrap(makeProvider().executablePath(forProgram: pluginBinaryName)) + let path = try XCTUnwrap(makeProvider().resolve(program: pluginBinaryName)?.path) XCTAssertEqual(path, pluginURL.path, "配置生成的 plugin 字段是 bundle 内绝对路径") XCTAssertTrue(path.hasPrefix(bundleRoot.path)) } func testMissingBinaryIsNotProvided() { - XCTAssertNil(makeProvider().executablePath(forProgram: pluginBinaryName)) + XCTAssertNil(makeProvider().resolve(program: pluginBinaryName)?.path) } func testNonExecutableBinaryIsNotProvided() throws { try writePluginBinary(permissions: 0o644) - XCTAssertNil(makeProvider().executablePath(forProgram: pluginBinaryName)) + XCTAssertNil(makeProvider().resolve(program: pluginBinaryName)?.path) } func testUnmanagedProgramIsNeverProvided() throws { try writePluginBinary(permissions: 0o755) let provider = makeProvider() - XCTAssertNil(provider.executablePath(forProgram: "obfs-local"), "集外程序名一律未提供") - XCTAssertNil(provider.executablePath(forProgram: "sslocal"), "非插件的 bundle 内二进制不算插件") - XCTAssertNil(provider.executablePath(forProgram: "../Helpers/sslocal"), "路径形态引用不拼接") - XCTAssertNil(provider.executablePath(forProgram: "v2ray-plugin/child"), "子路径引用不拼接") - XCTAssertNil(provider.executablePath(forProgram: "")) + XCTAssertNil(provider.resolve(program: "obfs-local")?.path, "集外程序名一律未提供") + XCTAssertNil(provider.resolve(program: "sslocal")?.path, "非插件的 bundle 内二进制不算插件") + XCTAssertNil(provider.resolve(program: "../Helpers/sslocal")?.path, "路径形态引用不拼接") + XCTAssertNil(provider.resolve(program: "v2ray-plugin/child")?.path, "子路径引用不拼接") + XCTAssertNil(provider.resolve(program: "")?.path) } /// 组合根事实:app bundle(生产默认 provider 的取值来源,经 AppArtifact 按 @@ -108,7 +108,7 @@ final class BundleManagedPluginProviderTests: XCTestCase { func testHostAppBundleProvidesManagedPlugin() throws { let path = try XCTUnwrap( BundleManagedPluginProvider(bundleURL: AppArtifact.bundleURL) - .executablePath(forProgram: pluginBinaryName), + .resolve(program: pluginBinaryName)?.path, "app bundle 未嵌入受管插件(先跑 fetch-external-binaries.sh)") XCTAssertTrue(path.hasSuffix("Contents/Helpers/Plugins/\(pluginBinaryName)")) } @@ -116,9 +116,9 @@ final class BundleManagedPluginProviderTests: XCTestCase { func testHostAppBundleProvidesCloakAliasesAndNotices() throws { let bundle = AppArtifact.bundleURL let provider = BundleManagedPluginProvider(bundleURL: bundle) - let path = try XCTUnwrap(provider.executablePath(forProgram: "cloak")) + let path = try XCTUnwrap(provider.resolve(program: "cloak")?.path) XCTAssertTrue(path.hasSuffix("Contents/Helpers/Plugins/ck-client")) - XCTAssertEqual(provider.executablePath(forProgram: "cloak-v2"), path) + XCTAssertEqual(provider.resolve(program: "cloak-v2")?.path, path) for name in ["LICENSE", "NOTICE", "THIRD-PARTY-NOTICES"] { XCTAssertTrue( FileManager.default.fileExists( diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift index 26055963..493f8e8f 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginCatalogTests.swift @@ -22,7 +22,7 @@ struct PluginCatalogTests { let snapshot = PluginCatalogSnapshot( mappings: ["v2ray-plugin": root.appendingPathComponent("missing").path], managed: BundleManagedPluginProvider(bundleURL: root)) - #expect(snapshot.executablePath(forProgram: "v2ray-plugin") == nil) + #expect(snapshot.resolve(program: "v2ray-plugin")?.path == nil) #expect(snapshot.entry(for: "v2ray-plugin")?.source == .user) } @Test @MainActor func savedMappingIsUsedAndRemovalRemovesCustomName() throws { @@ -35,7 +35,7 @@ struct PluginCatalogTests { 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().executablePath(forProgram: "custom-plugin") == 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) @@ -117,11 +117,11 @@ struct PluginCatalogTests { #expect( workflow.newFormPluginSection(selection: .named(program: "v2ray-plugin")).mappingsUnreadable) - #expect(catalog.executablePath(forProgram: "v2ray-plugin") == nil) + #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.executablePath(forProgram: "v2ray-plugin") == binary.path) + #expect(catalog.resolve(program: "v2ray-plugin")?.path == binary.path) } @Test @MainActor func failedPersistenceDoesNotPublishOrInvalidateRuntime() throws { @@ -156,14 +156,14 @@ struct PluginCatalogTests { store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")), inspector: QuietPluginInspector()) try catalog.addPlugin(program: " Tool ", path: link.path) - #expect(catalog.executablePath(forProgram: "Tool") == link.path) - #expect(catalog.executablePath(forProgram: "tool") == nil) + #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.executablePath(forProgram: "Tool") == nil) - #expect(captured.executablePath(forProgram: "Tool") == 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.executablePath(forProgram: "Other") == nil) + #expect(catalog.resolve(program: "Other")?.path == nil) try catalog.removePlugin(program: "Other") #expect(catalog.catalogSnapshot().entry(for: "Other") == nil) } @@ -293,11 +293,11 @@ struct PluginCatalogSecurityTests { await check.value if change == "unchanged" { #expect(catalog.securityFacts["tool"]?.policy == .rejected) - #expect(catalog.executablePath(forProgram: "tool") == binary.path) + #expect(catalog.resolve(program: "tool")?.path == binary.path) } else { #expect(catalog.securityFacts["tool"] == nil) #expect( - catalog.executablePath(forProgram: "tool") + catalog.resolve(program: "tool")?.path == (["remove", "permissions"].contains(change) ? nil : binary.path)) } } @@ -315,8 +315,8 @@ struct PluginCatalogSecurityTests { mappings: ["directory": directoryLink.path, "missing": missingLink.path]) #expect(snapshot.entry(for: "directory")?.availability == .notExecutable) #expect(snapshot.entry(for: "missing")?.availability == .missing) - #expect(snapshot.executablePath(forProgram: "directory") == nil) - #expect(snapshot.executablePath(forProgram: "missing") == nil) + #expect(snapshot.resolve(program: "directory")?.path == nil) + #expect(snapshot.resolve(program: "missing")?.path == nil) } @Test func failedFileInspectionIsDistinctFromMissingFile() { @@ -324,7 +324,7 @@ struct PluginCatalogSecurityTests { mappings: ["tool": "/unreadable/tool"], managed: BundleManagedPluginProvider(fileManager: UnreadablePluginFileManager())) #expect(snapshot.entry(for: "tool")?.availability == .unreadable) - #expect(snapshot.executablePath(forProgram: "tool") == nil) + #expect(snapshot.resolve(program: "tool")?.path == nil) } @Test func userDiagnosticFactsNeverInheritManagedVersion() { diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift index 7d5fb093..84134695 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift @@ -38,7 +38,7 @@ struct PluginMappingCommitTests { } #expect(fixture.plugins.testMappings["tool"] == fixture.second.path) let restored = PluginCatalog(store: fixture.store, inspector: CommitPluginInspector()) - #expect(restored.executablePath(forProgram: "tool") == fixture.second.path) + #expect(restored.resolve(program: "tool")?.path == fixture.second.path) } @Test func unrelatedAndIdenticalMappingsDoNotRestartHealthyRuntime() async throws {