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 {