mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SslocalServerDocument, ActivationFailure> {
|
||||
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<String, ActivationFailure> {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// 加密方式能力目录(与当前随 app 打包的 sslocal 版本保持一致)。
|
||||
/// 目录值是本地可验证的能力事实,不代表远端服务器一定可达。
|
||||
enum EncryptionMethodCatalog {
|
||||
static let supported: Set<String> = [
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// 加密方式能力目录(与当前随 app 打包的 sslocal 版本保持一致)。
|
||||
/// 目录值是本地可验证的能力事实,不代表远端服务器一定可达。
|
||||
enum EncryptionMethodCatalog {
|
||||
static let supported: Set<String> = [
|
||||
"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<String, LeafInvalidationReason> {
|
||||
do {
|
||||
guard let secret = try credentials.secret(for: reference), !secret.isEmpty else {
|
||||
return .failure(.credentialUnresolved(reference))
|
||||
}
|
||||
return .success(secret)
|
||||
} catch {
|
||||
return .failure(.credentialReadFailed(reference))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, "无插件选项引用时不解析选项")
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user