feat(plugins): add user plugin catalog and runtime wiring

This commit is contained in:
Qiu Yuzhou
2026-10-05 00:28:16 +08:00
parent 87701fbacd
commit 128efcc0d6
35 changed files with 1231 additions and 88 deletions
+7 -1
View File
@@ -54,7 +54,13 @@ _避免用词_:可移除节点(Removable node)。
没有已知本地阻断问题、可用于生成代理配置的服务器配置。它不保证远程可达,也不表示插件参数已经验证有效。
**托管插件(Managed plugin)**:
由产品随应用发布的 SIP003 插件,用户可选择的插件程序限定在这一集合中。
由产品随应用发布的 SIP003 插件,其发布版本与来源事实属于随应用提供的程序,不代表用户自备的同名程序。
**用户插件映射(User plugin mapping)**:
当前 macOS 用户维护的插件名称与本机可执行文件路径之间的对应关系。同名映射覆盖托管插件的默认选择,但不改写服务器配置中的插件引用。
**有效插件目录(Effective plugin catalog)**:
由当前应用提供的托管插件与用户插件映射共同确定的插件名称、实际来源与可用性事实集合。失效的用户映射仍保留其覆盖关系,不自动退回同名托管插件。
**旧版配置(Legacy configuration)**:
冻结的旧版客户端保存的服务器列表与偏好设置。
@@ -83,7 +83,7 @@ extension CatalogWorkflow {
}
fields.pluginProgram = nil
fields.pluginOptionsRef = nil
case .managed(let program):
case .named(let program):
fields.pluginProgram = program
let trimmedOptions = (options ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedOptions.isEmpty {
@@ -97,7 +97,7 @@ final class CatalogWorkflow: ObservableObject {
isEditable: entry.source == .manual, plugin: pluginPresentation(for: fields))
}
/// 服务器编辑面(显式命令,story 11):解析密码与受管插件参数明文。
/// 服务器编辑面(显式命令,story 11):解析密码与目录中的具名插件参数明文。
/// 节点不存在或不是服务器叶子为 `nil`。
func serverEditForm(for id: NodeID) throws -> ServerEditForm? {
guard let entry = dependencies.coordinator.committedCatalog.entry(for: id),
@@ -129,48 +129,51 @@ final class CatalogWorkflow: ObservableObject {
/// 插件区状态(#38):集内引用给可执行文件存在性事实与参数明文;集外引用
/// 以显式 unknown 呈现(原样保留,激活语义由状态机点名拒绝)。
private func pluginPresentation(for fields: ServerFields) -> PluginSectionState {
let snapshot = dependencies.plugins.catalogSnapshot()
let selection: PluginSelection
if let program = fields.pluginProgram {
selection =
ManagedPluginCatalog.info(forProgram: program) != nil
? .managed(program: program) : .unknown(program: program)
snapshot.entry(for: program) != nil
? .named(program: program) : .unknown(program: program)
} else {
selection = .none
}
let provided: Bool
if case .managed(let program) = selection {
provided = dependencies.plugins.executablePath(forProgram: program) != nil
if case .named(let program) = selection {
provided = snapshot.executablePath(forProgram: program) != nil
} else {
provided = false
}
return PluginSectionState(
selection: selection, managed: ManagedPluginCatalog.plugins, provided: provided,
optionsPresent: fields.pluginOptionsRef != nil, options: "")
optionsPresent: fields.pluginOptionsRef != nil, options: "",
programs: snapshot.entries.map(\.program))
}
private func loadedPluginState(for fields: ServerFields) throws -> PluginSectionState {
let facts = pluginPresentation(for: fields)
let options: String
if case .managed = facts.selection, let reference = fields.pluginOptionsRef {
if case .named = facts.selection, let reference = fields.pluginOptionsRef {
options = try requiredServerSecret(reference)
} else {
options = ""
}
return PluginSectionState(
selection: facts.selection, managed: facts.managed, provided: facts.provided,
optionsPresent: facts.optionsPresent, options: options)
optionsPresent: facts.optionsPresent, options: options, programs: facts.programs)
}
/// 新建表单的插件区状态:无既有引用与既有参数,选中态跟随草稿。新表单
/// 只能产生「无」或受管选择,`provided` 恒为真——新建尚无引用可点名,
/// 只能产生「无」或目录中的具名选择,`provided` 恒为真——新建尚无引用可点名,
/// 可执行文件缺失由激活语义拒绝并在编辑面呈现。
func newFormPluginSection(selection: PluginSelection) -> PluginSectionState {
PluginSectionState(
let snapshot = dependencies.plugins.catalogSnapshot()
return PluginSectionState(
selection: selection,
managed: ManagedPluginCatalog.plugins,
provided: true,
optionsPresent: false,
options: "")
options: "", programs: snapshot.entries.map(\.program))
}
/// 添加落点:选中手动分组 → 组内;选中服务器 → 其父组(仅手动);其余 → 根。
@@ -194,7 +197,7 @@ final class CatalogWorkflow: ObservableObject {
/// 全新 UUID,不按内容去重(GLOSSARY.md 不变量);返回新节点身份供选中。
@discardableResult
func createServer(_ draft: ServerEditDraft, into parent: NodeID?) async throws -> NodeID {
let draft = try Self.prepareServerDraft(draft)
let draft = try prepareServerDraft(draft)
var journal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
return try commit { [self] catalog in
@@ -256,9 +259,9 @@ final class CatalogWorkflow: ObservableObject {
/// 服务器编辑提交(story 12/13):配置与凭据作为一个逻辑变更;凭据写入经
/// journal 记录原值,提交失败时全部恢复旧秘密,恢复结果经
/// `CommitError.credentialRollback` 报出。插件选择按 #38 语义
/// 落盘(「无」整体清除、受管写程序名与参数、集外引用原样保留)。
/// 落盘(「无」整体清除、具名选择写程序名与参数、集外引用原样保留)。
func updateServer(_ id: NodeID, draft: ServerEditDraft) async throws {
let draft = try Self.prepareServerDraft(draft)
let draft = try prepareServerDraft(draft)
var journal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
try commit { [self] catalog in
@@ -282,7 +285,7 @@ final class CatalogWorkflow: ObservableObject {
/// 新建与编辑共享的草稿准备:校验在凭据 journal 与目录提交之前完成。
/// 仅手动表单拒绝空名称;导入通过 ServerFields 的构造规则补名。
private static func prepareServerDraft(_ input: ServerEditDraft) throws -> ServerEditDraft {
private func prepareServerDraft(_ input: ServerEditDraft) throws -> ServerEditDraft {
var draft = input
draft.address = draft.address.trimmingCharacters(in: .whitespaces)
guard !draft.address.isEmpty else { throw ServerFormError.invalidAddress }
@@ -293,10 +296,10 @@ final class CatalogWorkflow: ObservableObject {
throw ServerFormError.unsupportedEncryptionMethod(draft.encryptionMethod)
}
guard !draft.password.isEmpty else { throw ServerFormError.invalidPassword }
if case .managed(let program) = draft.plugin,
ManagedPluginCatalog.info(forProgram: program) == nil
if case .named(let program) = draft.plugin,
dependencies.plugins.catalogSnapshot().entry(for: program) == nil
{
throw ServerFormError.pluginNotManaged(program)
throw ServerFormError.pluginUnknown(program)
}
draft.remark = draft.remark.trimmingCharacters(in: .whitespacesAndNewlines)
guard !draft.remark.isEmpty else { throw ServerFormError.emptyName }
@@ -13,7 +13,7 @@ struct CatalogWorkflowDependencies {
/// 凭据存储(story 11):只在 module 内出现,UI 不持有。
let credentials: CredentialStoring
/// 受管插件提供缝(编辑面集内事实与可执行文件存在性)。
let plugins: ManagedPluginProviding
let plugins: PluginExecutableResolving
/// 订阅获取缝。
let subscriptionFetcher: SubscriptionFetching
/// Legacy 导入服务(快照发现、完成标记与原子导入)。
@@ -138,8 +138,9 @@ extension CatalogTreeSnapshot {
static func build(
from catalog: ConfigurationCatalog,
credentials: CredentialStoring,
plugins: ManagedPluginProviding
plugins: PluginExecutableResolving
) -> CatalogTreeSnapshot {
let plugins = plugins.catalogSnapshot()
func buildNode(_ id: NodeID, entry: CatalogEntry, parentID: NodeID?) -> CatalogTreeNode {
switch entry.kind {
case .group(let fields):
@@ -267,12 +268,12 @@ struct CommitError: Error {
let credentialRollback: CredentialRollbackOutcome
}
/// 插件选择器选中态(D10):「无」、受管集内程序、受管集外引用。集外引用
/// (Legacy 导入或订阅带入)以显式「本版本未提供」状态呈现并原样保留。
/// 插件选择器选中态:无、目录中的名称引用、未解析的导入引用。
/// 托管或用户来源由有效目录解释,不参与选中值身份。
/// Hashable 以直接充当 SwiftUI Picker 的选中值。
enum PluginSelection: Hashable {
case none
case managed(program: String)
case named(program: String)
case unknown(program: String)
}
@@ -281,12 +282,13 @@ struct PluginSectionState: Equatable {
let selection: PluginSelection
/// 本版本受管集(「无」不由这里提供)。
let managed: [ManagedPluginInfo]
/// 当前受管引用的可执行文件是否在位(生成配置时的存在性检查事实)。
/// 当前具名引用的可执行文件是否在位(生成配置时的存在性检查事实)。
let provided: Bool
/// 参数是否已配置(存于钥匙串)。
let optionsPresent: Bool
/// 参数明文(仅受管选中态解析,供参数输入框预填;其余为空串)。
/// 参数明文(仅目录中的具名选中态解析,供参数输入框预填;其余为空串)。
let options: String
var programs: [String]?
}
// MARK: - 订阅 projection
@@ -365,8 +367,8 @@ enum ServerFormError: Error, Equatable {
case unsupportedEncryptionMethod(String)
case invalidPassword
case emptyName
/// 提交了受管集之外的插件选择(表单只能产生受管集内的选择,此为程序错误防线的显式拒绝)。
case pluginNotManaged(String)
/// 提交了有效目录之外的插件选择;拒绝陈旧或未知的表单选择。
case pluginUnknown(String)
}
/// 订阅表单级失败(URL 门禁、订阅不存在);获取/解析失败仍以原错误上抛。
+1 -1
View File
@@ -196,7 +196,7 @@ extension AppPresentation {
case .unsupportedEncryptionMethod: return "当前 sslocal 不支持该加密方法"
case .invalidPassword: return "密码不能为空"
case .emptyName: return "名称不能为空"
case .pluginNotManaged: return "本版本未提供所选插件"
case .pluginUnknown: return "本版本未提供所选插件"
}
}
+26 -6
View File
@@ -142,13 +142,22 @@ private struct AppComposition {
// access to this same in-process source.
let catalogBootstrap = CatalogCommitCoordinator.bootstrap(
fileStore: dependencies.catalogFileStore)
let plugins = PluginCatalog()
let controller = makeRuntimeController(
dependencies: dependencies, bootstrap: catalogBootstrap)
dependencies: dependencies, bootstrap: catalogBootstrap, plugins: plugins)
let loginController = LaunchAtLoginController(service: dependencies.loginService)
let catalogWorkflow = makeCatalogWorkflow(
dependencies: dependencies,
controller: controller,
bootstrap: catalogBootstrap)
bootstrap: catalogBootstrap, plugins: plugins)
plugins.connect(
invalidateRuntime: { [weak controller] in controller?.pluginMappingsDidPublish() },
revalidate: { [weak catalogWorkflow] in catalogWorkflow?.republishCommittedState() },
converge: { [weak controller] in
guard let controller else { return }
_ = await controller.catalogDidCommit(
snapshot: controller.catalogSnapshotReader.catalogSnapshot)
})
// 代理控制工作流 module(issue #47):状态菜单等 UI 表面的唯一代理控制
// seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行
// 时语义);目录目标事实经窄缝从目录工作流读取;本机接口事实经独立缝
@@ -164,7 +173,14 @@ private struct AppComposition {
// 实例供诊断侧栏与详情共同使用。
let diagnosticsWorkflow = DiagnosticsWorkflow(
runtimeFacts: controller,
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts })
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts },
managedPlugins: {
plugins.catalogSnapshot().entries.map { entry in
DiagnosticPluginFacts(
program: entry.program, version: entry.managedInfo?.release ?? "",
present: entry.availability == .available, source: entry.source)
}
})
let workspaceRoute = WorkspaceRoute()
let silentLaunch = SilentLaunchController(store: dependencies.silentLaunchStore)
let expansion = CatalogExpansionState()
@@ -204,13 +220,16 @@ private struct AppComposition {
/// 系统服务适配器。
private static func makeRuntimeController(
dependencies: ApplicationDependencies,
bootstrap: CatalogCommitBootstrap
bootstrap: CatalogCommitBootstrap,
plugins: PluginCatalog
) -> ProxyRuntimeController {
ProxyRuntimeController(
catalogSnapshotReader: bootstrap.catalogSnapshotReader,
activationFileStore: dependencies.activationFileStore,
runtimeFileStore: dependencies.runtimeFileStore,
credentials: dependencies.credentials,
plugins: plugins,
refreshPluginSecurityFacts: { [weak plugins] in _ = plugins?.refreshSecurityFacts() },
listenRestore: dependencies.listenRestore,
settingsStore: dependencies.settingsStore,
settingsRestore: dependencies.settingsRestore,
@@ -223,7 +242,8 @@ private struct AppComposition {
private static func makeCatalogWorkflow(
dependencies: ApplicationDependencies,
controller: ProxyRuntimeController,
bootstrap: CatalogCommitBootstrap
bootstrap: CatalogCommitBootstrap,
plugins: PluginCatalog
) -> CatalogWorkflow {
let coordinator = CatalogCommitCoordinator(
fileStore: dependencies.catalogFileStore,
@@ -233,7 +253,7 @@ private struct AppComposition {
dependencies: CatalogWorkflowDependencies(
coordinator: coordinator,
credentials: dependencies.credentials,
plugins: BundleManagedPluginProvider(),
plugins: plugins,
subscriptionFetcher: HTTPSSubscriptionFetcher(),
legacyImportService: dependencies.legacyImportService,
postLegacyImport: { _ in
@@ -11,6 +11,7 @@ extension ProxyRuntimeController {
/// 自含返回;意外错误 throws 并进入 `serviceFailed`。
@discardableResult
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
refreshPluginSecurityFacts()
let catalog = catalogSnapshotReader.catalogSnapshot
do {
let configuration = try machine.activate(
@@ -114,6 +115,7 @@ extension ProxyRuntimeController {
/// GUI startup restores the Agent. Healthy proxy intent only inspects; unhealthy
/// proxy intent suspends; disabled intent never writes SystemConfiguration.
func resyncOnLaunch() async {
refreshPluginSecurityFacts()
systemProxyObserver.beginStartupResync()
guard settings.agentEnabled else {
await stopAgent()
@@ -157,7 +159,10 @@ extension ProxyRuntimeController {
/// 普通部署意图;配置派生、执行与失败呈现由运行状态收敛 module 拥有。
@discardableResult
func deploy(_ sourceDocument: SslocalRuntimeDocument) async -> ConvergenceResult {
await convergeRuntime(.deployment(sourceDocument))
refreshPluginSecurityFacts()
let result = await convergeRuntime(.deployment(sourceDocument))
if case .failed = result { refreshPluginSecurityFacts() }
return result
}
/// 活动目标失效(issue #60):清除并持久化 nil,点名原因独立呈现;agent
@@ -3,6 +3,11 @@ import Foundation
// MARK: - 设置变更与目录提交同步
extension ProxyRuntimeController {
/// A published mapping invalidates prepared documents before asynchronous convergence.
func pluginMappingsDidPublish() {
runtimePreparationGeneration += 1
}
/// 目录提交协调器的生产适配入口(issue #40):以刚提交的内存快照重展开,
/// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。agent 意图开启时
/// 原子更新运行时;目标失效 → 清除目标但 agent 继续监听。返回结构化收敛
@@ -50,7 +50,8 @@ final class ProxyRuntimeController {
let activationFileStore: ActivationStateFileStore
let runtimeFileStore: RuntimeFileStore
let credentials: CredentialStoring
let plugins: ManagedPluginProviding
let plugins: PluginExecutableResolving
let refreshPluginSecurityFacts: @MainActor () -> Void
let settingsStore: ProxySettingsStoring
/// 自定义规则持久化(issue #66):规则模式 ACL 合并的用户入口。
var isUpdatingRules = false
@@ -123,7 +124,8 @@ final class ProxyRuntimeController {
fileURL: ActivationStateFileStore.defaultFileURL()),
runtimeFileStore: RuntimeFileStore = RuntimeFileStore(),
credentials: CredentialStoring = KeychainCredentialStore(),
plugins: ManagedPluginProviding = BundleManagedPluginProvider(),
plugins: PluginExecutableResolving = BundleManagedPluginProvider(),
refreshPluginSecurityFacts: @escaping @MainActor () -> Void = {},
listenRestore: RestoredListenSettings = ListenSettingsFileStore.restored(),
settingsStore: ProxySettingsStoring = ProxySettingsFileStore(),
customRuleStore: CustomRuleStore = CustomRuleStore(),
@@ -157,6 +159,7 @@ final class ProxyRuntimeController {
self.runtimeFileStore = runtimeFileStore
self.credentials = credentials
self.plugins = plugins
self.refreshPluginSecurityFacts = refreshPluginSecurityFacts
self.settingsStore = settingsStore
self.customRuleStore = customRuleStore
self.ruleDocuments = RuleDocumentSession(store: customRuleStore)
+4 -2
View File
@@ -46,10 +46,12 @@ struct ServerDetailView: View {
fields.showServer(serverID, load: workflow.serverEditForm)
}
.onReceive(workflow.$tree) { _ in
fields.updatePresentation(workflow.serverFormPresentation(for: serverID))
fields.updatePresentation(
workflow.serverFormPresentation(for: serverID), load: workflow.serverEditForm)
}
.onReceive(workflow.subscriptionServerRefreshes) { affected in
fields.updatePresentation(workflow.serverFormPresentation(for: serverID))
fields.updatePresentation(
workflow.serverFormPresentation(for: serverID), load: workflow.serverEditForm)
fields.subscriptionDidRefresh(affectedServers: affected, load: workflow.serverEditForm)
}
}
+38 -1
View File
@@ -190,7 +190,44 @@ final class ServerFormFields: ObservableObject {
reloadServer(load: load)
}
func updatePresentation(_ facts: ServerFormPresentation?) {
func updatePresentation(
_ facts: ServerFormPresentation?,
load: ((NodeID) throws -> ServerEditForm?)? = nil
) {
if let selection = facts?.plugin.selection,
presentation?.plugin.selection != selection
{
let oldProgram: String?
switch pluginChoice {
case .named(let program), .unknown(let program): oldProgram = program
case .none: oldProgram = nil
}
let newProgram: String?
switch selection {
case .named(let program), .unknown(let program): newProgram = program
case .none: newProgram = nil
}
if let oldProgram, oldProgram == newProgram,
savedDraft?.plugin == pluginChoice
{
// Only refresh plugin facts. Independent unsaved server fields stay intact.
if case .unknown = pluginChoice, case .named = selection,
let serverID, let load,
pluginOptions.composedString == savedDraft?.pluginOptions
{
do {
if let form = try load(serverID) {
pluginOptions.load(form.plugin.options)
savedDraft?.pluginOptions = pluginOptions.composedString
}
} catch {
loadFailure = .credentialsUnavailable
}
}
pluginChoice = selection
savedDraft?.plugin = selection
}
}
if presentation != facts { presentation = facts }
}
@@ -1,6 +1,6 @@
import SwiftUI
/// 服务器详情的插件区(issue #38/D10,地图 #52 票 #55):受管选择器——
/// 服务器详情的插件区(issue #38/D10,地图 #52 票 #55):有效插件目录选择器——
/// 「无」+ 受管列表;集外引用(Legacy 导入/订阅带入)追加显式「本版本未
/// 提供」项使当前状态可见并原样保留。选中受管项才显示参数编辑区与可用性
/// 警告。编辑面与订阅只读面是两个独立视图(issue #81),都经共享的模式
@@ -26,8 +26,8 @@ struct ServerPluginSection: View {
if let plugin {
Picker("插件", selection: $selection) {
Text("无").tag(PluginSelection.none)
ForEach(plugin.managed, id: \.program) { info in
Text(info.program).tag(PluginSelection.managed(program: info.program))
ForEach(plugin.programs ?? plugin.managed.map(\.program), id: \.self) { program in
Text(program).tag(PluginSelection.named(program: program))
}
if case .unknown(let program) = plugin.selection {
Text("\(program)(本版本未提供)").tag(PluginSelection.unknown(program: program))
@@ -47,8 +47,8 @@ struct ServerPluginSection: View {
switch selection {
case .none:
EmptyView()
case .managed(let program):
managedDetails(program: program, plugin: plugin)
case .named(let program):
namedPluginDetails(program: program, plugin: plugin)
case .unknown(let program):
unknownNotice(program: program, plugin: plugin)
}
@@ -57,12 +57,12 @@ struct ServerPluginSection: View {
/// 选中受管项:参数编辑区与可用性警告;订阅只读走独立只读视图。
@ViewBuilder
private func managedDetails(program: String, plugin: PluginSectionState) -> some View {
if plugin.managed.contains(where: { $0.program == program }) {
private func namedPluginDetails(program: String, plugin: PluginSectionState) -> some View {
if (plugin.programs ?? plugin.managed.map(\.program)).contains(program) {
VStack(alignment: .leading, spacing: 8) {
if !plugin.provided {
Label(
"受管插件可执行文件缺失:激活会被点名拒绝;请重新安装本 app。",
"不可用",
systemImage: "exclamationmark.triangle.fill"
)
.font(.footnote)
@@ -38,7 +38,7 @@ struct ActivationStateMachine: Equatable, Sendable {
_ target: NodeID,
in catalog: ConfigurationCatalog,
credentials: CredentialStoring,
plugins: ManagedPluginProviding,
plugins: PluginExecutableResolving,
options: RuntimeDocumentOptions
) throws -> RuntimeConfiguration {
let outcome = derive(
@@ -60,7 +60,7 @@ struct ActivationStateMachine: Equatable, Sendable {
mutating func catalogDidCommit(
_ catalog: ConfigurationCatalog,
credentials: CredentialStoring,
plugins: ManagedPluginProviding,
plugins: PluginExecutableResolving,
options: RuntimeDocumentOptions
) -> ActivationEffect? {
guard let target = activeTargetID else { return nil }
@@ -84,10 +84,11 @@ struct ActivationStateMachine: Equatable, Sendable {
target: NodeID,
in catalog: ConfigurationCatalog,
credentials: CredentialStoring,
plugins: ManagedPluginProviding,
plugins: PluginExecutableResolving,
options: RuntimeDocumentOptions
) -> Result<RuntimeConfiguration, ActivationFailure> {
guard catalog.contains(target) else { return .failure(.targetNotFound(target)) }
let plugins = plugins.catalogSnapshot()
let isGroup = isGroupTarget(target, in: catalog)
var leaves: [NodeID] = []
if isGroup {
@@ -142,7 +143,7 @@ struct ActivationStateMachine: Equatable, Sendable {
_ leafID: NodeID,
in catalog: ConfigurationCatalog,
credentials: CredentialStoring,
plugins: ManagedPluginProviding
plugins: PluginExecutableResolving
) -> LeafDerival {
guard let entry = catalog.entry(for: leafID), case .server(let fields) = entry.kind else {
preconditionFailure("展开结果只含服务器叶子")
@@ -190,7 +191,7 @@ struct ActivationStateMachine: Equatable, Sendable {
_ leafID: NodeID,
in catalog: ConfigurationCatalog,
credentials: CredentialStoring,
plugins: ManagedPluginProviding
plugins: PluginExecutableResolving
) -> Result<SslocalServerDocument, ActivationFailure> {
guard let entry = catalog.entry(for: leafID), case .server(let fields) = entry.kind else {
preconditionFailure("展开结果只含服务器叶子")
@@ -122,6 +122,7 @@ struct DiagnosticPluginFacts: Equatable, Sendable {
let program: String
let version: String
let present: Bool
var source: PluginCatalogSnapshot.Source = .managed
}
/// 诊断报告构建器(spec #21 D5,issue #34):只输出状态、存在性、权限、
@@ -226,8 +227,10 @@ enum DiagnosticReportBuilder {
lines.append("(本版本未打包任何插件)")
} else {
for plugin in snapshot.managedPlugins {
let version = plugin.source == .managed ? plugin.version : plugin.source.rawValue
lines.append(
"- \(plugin.program) \(plugin.version):\(plugin.present ? "已提供" : "缺失")")
"- \(plugin.program) \(version):\(plugin.present ? "已提供" : "缺失")"
)
}
}
lines.append("")
@@ -0,0 +1,156 @@
import Darwin
import Foundation
struct PluginSecurityFacts: Equatable, Sendable {
enum Quarantine: Sendable { case present, absent, unknown }
enum Signature: Sendable { case valid, unsigned, invalid, notApplicable, unknown }
enum Policy: Sendable { case accepted, rejected, notApplicable, unknown }
let quarantine: Quarantine
let signature: Signature
let policy: Policy
}
protocol PluginInspecting: Sendable {
func inspect(_ executable: URL) async -> PluginSecurityFacts
}
struct PluginInspectionCommandResult: Sendable {
let status: Int32
let output: String
}
/// Read-only evidence, not a prediction that exec will succeed or a security guarantee.
struct MacOSPluginInspection: PluginInspecting {
typealias CommandRunner = @Sendable (URL, [String]) -> PluginInspectionCommandResult?
private let runCommand: CommandRunner
init(
runCommand: @escaping CommandRunner = { tool, arguments in Self.run(tool, arguments: arguments)
}
) {
self.runCommand = runCommand
}
func inspect(_ executable: URL) async -> PluginSecurityFacts {
await Task.detached(priority: .utility) { inspectSynchronously(executable) }.value
}
private func inspectSynchronously(_ executable: URL) -> PluginSecurityFacts {
let attributeSize = getxattr(executable.path, "com.apple.quarantine", nil, 0, 0, 0)
let quarantine: PluginSecurityFacts.Quarantine =
attributeSize >= 0 ? .present : (errno == ENOATTR ? .absent : .unknown)
let signature: PluginSecurityFacts.Signature
let header: Data? = {
guard let handle = try? FileHandle(forReadingFrom: executable) else { return nil }
defer { try? handle.close() }
return try? handle.read(upToCount: 4)
}()
if header?.starts(with: [0x23, 0x21]) == true {
signature = .notApplicable
} else if let result = runCommand(
URL(fileURLWithPath: "/usr/bin/codesign"),
["--verify", "--strict", "--verbose=2", executable.path])
{
if result.status == 0 {
signature = .valid
} else if result.output.contains("not signed at all") {
signature = .unsigned
} else if result.output.contains("invalid signature")
|| result.output.contains("code or signature have been modified")
{
signature = .invalid
} else {
signature = .unknown
}
} else {
signature = .unknown
}
let policy: PluginSecurityFacts.Policy
if let result = runCommand(
URL(fileURLWithPath: "/usr/sbin/spctl"),
["--assess", "--type", "execute", "--verbose=2", executable.path])
{
if result.status == 0 {
policy = .accepted
} else if result.output.contains("does not seem to be an app")
|| result.output.contains("Insufficient Context")
{
policy = .notApplicable
} else if result.output.contains(": rejected") {
policy = .rejected
} else {
policy = .unknown
}
} else {
policy = .unknown
}
return PluginSecurityFacts(quarantine: quarantine, signature: signature, policy: policy)
}
/// Use a private output file to avoid pipe deadlocks; only read a bounded prefix.
/// Only fixed Apple tools run here. The plugin itself is never executed.
private static func run(_ tool: URL, arguments: [String]) -> PluginInspectionCommandResult? {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("plugin-inspection-\(UUID().uuidString)")
guard
(try? FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])) != nil
else { return nil }
defer { try? FileManager.default.removeItem(at: directory) }
let outputURL = directory.appendingPathComponent("output")
guard
FileManager.default.createFile(
atPath: outputURL.path, contents: nil, attributes: [.posixPermissions: 0o600]),
let output = try? FileHandle(forWritingTo: outputURL)
else { return nil }
defer { try? output.close() }
let process = Process()
process.executableURL = tool
process.arguments = arguments
process.standardOutput = output
process.standardError = output
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do { try process.run() } catch { return nil }
guard finished.wait(timeout: .now() + 3) == .success else {
// A timed-out assessment is unknown, never rejected.
kill(process.processIdentifier, SIGKILL)
_ = finished.wait(timeout: .now() + 1)
return nil
}
guard let input = try? FileHandle(forReadingFrom: outputURL) else { return nil }
defer { try? input.close() }
guard let data = try? input.read(upToCount: 16_384) else { return nil }
return PluginInspectionCommandResult(
status: process.terminationStatus, output: String(bytes: data, encoding: .utf8) ?? "")
}
}
/// Reject evidence for another file, including a changed symlink target.
struct PluginFileIdentity: Equatable, Sendable {
let resolvedPath: String
let inode: UInt64
let device: Int32
let size: Int64
let modifiedSeconds: Int
let modifiedNanoseconds: Int
let changedSeconds: Int
let changedNanoseconds: Int
let permissions: UInt16
init?(path: String) {
let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath()
var facts = stat()
guard stat(resolved.path, &facts) == 0 else { return nil }
self.resolvedPath = resolved.path
self.inode = facts.st_ino
self.device = facts.st_dev
self.size = facts.st_size
self.modifiedSeconds = facts.st_mtimespec.tv_sec
self.modifiedNanoseconds = facts.st_mtimespec.tv_nsec
self.changedSeconds = facts.st_ctimespec.tv_sec
self.changedNanoseconds = facts.st_ctimespec.tv_nsec
self.permissions = facts.st_mode
}
}
@@ -1,10 +1,22 @@
import Foundation
/// 受管插件提供缝(spec #21 D10,GLOSSARY.md「Managed plugin」):给定服务器
/// 持有的插件程序引用,返回本版本提供的 bundle 内绝对路径;返回 nil 即
/// 「本版本未提供」,该引用使叶子成为无效激活候选(激活点名拒绝)。
protocol ManagedPluginProviding {
/// 插件解析 seam:当前路径查询与一次派生使用的只读目录快照。
/// nil 表示本地不可用;托管与用户映射共用这一激活语义。
protocol PluginExecutableResolving {
func executablePath(forProgram program: String) -> String?
func catalogSnapshot() -> PluginCatalogSnapshot
}
extension PluginExecutableResolving {
func catalogSnapshot() -> PluginCatalogSnapshot {
PluginCatalogSnapshot(
entries: ManagedPluginCatalog.plugins.map { info in
let path = executablePath(forProgram: info.program)
return PluginCatalogSnapshot.Entry(
program: info.program, path: path, source: .managed,
availability: path == nil ? .missing : .available, managedInfo: info)
})
}
}
/// 受管插件静态事实(#38):来源项目、许可证、固定版本与重签标识,供编辑器
@@ -25,8 +37,7 @@ struct ManagedPluginInfo: Equatable, Sendable {
}
/// 本版本受管集(D10):首版仅 v2ray-plugin v1.3.2;shadow-tls、Cloak 暂缓,
/// simple-obfs、kcptun、GoQuiet、simple-tls 明确不支持(「不支持某插件」是
/// 显式产品状态,不是可扩展的用户自备通道)。
/// 此目录只描述随 app 发布的程序;用户自备程序由 PluginCatalog 的本机映射提供。
enum ManagedPluginCatalog {
static let plugins: [ManagedPluginInfo] = [
ManagedPluginInfo(
@@ -47,7 +58,7 @@ enum ManagedPluginCatalog {
/// 仅在生成配置时检查可执行文件存在,运行时不逐次哈希复验(bundle 签名 +
/// 公证即信任边界);受管集外引用一律视为「本版本未提供」,路径只从静态表
/// 派生,不拼接任何用户输入。
struct BundleManagedPluginProvider: ManagedPluginProviding {
struct BundleManagedPluginProvider: PluginExecutableResolving {
/// 插件宿主 bundle 根;生产为应用 bundle,测试注入临时目录。
var bundleURL: URL = Bundle.main.bundleURL
var fileManager: FileManager = .default
@@ -65,6 +76,6 @@ struct BundleManagedPluginProvider: ManagedPluginProviding {
/// 显式「本版本什么都不提供」的实现(测试与不涉插件的场景注入用):任何插件
/// 引用都是无效激活候选(点名原因),不是静默失败。
struct NoManagedPluginProvider: ManagedPluginProviding {
struct NoManagedPluginProvider: PluginExecutableResolving {
func executablePath(forProgram program: String) -> String? { nil }
}
+186
View File
@@ -0,0 +1,186 @@
import Combine
import Foundation
/// Owns mapping intent, publication and asynchronous post-commit work.
/// File availability is captured anew for each derivation, not cached for the session.
final class PluginCatalog: ObservableObject, PluginExecutableResolving {
enum Change {
case add(program: String, path: String)
case update(program: String, path: String)
case remove(program: String)
case rename(program: String, newProgram: String, path: String)
}
private struct State {
var mappings: [String: String]
var generation = 0
var unreadable: Bool
}
private let lock = NSLock()
private var state: State
private let store: any PluginMappingStoring
private let managed: BundleManagedPluginProvider
private let inspector: any PluginInspecting
@MainActor @Published private(set) var securityFacts: [String: PluginSecurityFacts] = [:]
@MainActor private var inspectionTasks: [String: Task<Void, Never>] = [:]
let didCommit = PassthroughSubject<Int, Never>()
@MainActor private var invalidateRuntime: (() -> Void)?
@MainActor private var revalidate: (() -> Void)?
@MainActor private var converge: (() async -> Void)?
/// Composition-only wiring. UI submits mapping intent rather than scheduling effects.
@MainActor
func connect(
invalidateRuntime: @escaping () -> Void,
revalidate: @escaping () -> Void,
converge: @escaping () async -> Void
) {
self.invalidateRuntime = invalidateRuntime
self.revalidate = revalidate
self.converge = converge
}
init(
store: any PluginMappingStoring = PluginMappingFileStore(),
managed: BundleManagedPluginProvider = BundleManagedPluginProvider(),
inspector: any PluginInspecting = MacOSPluginInspection()
) {
self.store = store
self.managed = managed
self.inspector = inspector
do {
state = State(mappings: try store.load(), unreadable: false)
} catch {
state = State(mappings: [:], unreadable: true)
}
}
func catalogSnapshot() -> PluginCatalogSnapshot {
let captured = lock.withLock { state }
return PluginCatalogSnapshot(
mappings: captured.mappings, managed: managed, generation: captured.generation,
mappingsUnreadable: captured.unreadable)
}
func executablePath(forProgram program: String) -> String? {
catalogSnapshot().executablePath(forProgram: program)
}
var userMappings: [String: String] { lock.withLock { state.mappings } }
@MainActor
func commit(_ changes: [Change]) throws {
let captured = lock.withLock { state }
guard !captured.unreadable else { throw PluginMappingError.unreadable }
var proposed = captured.mappings
for change in changes {
switch change {
case .add(let program, let path):
let name = try validatedName(program)
guard proposed[name] == nil else { throw PluginMappingError.nameExists }
proposed[name] = try validatedPath(path)
case .update(let program, let path):
guard proposed[program] != nil else { throw PluginMappingError.nameMissing }
proposed[program] = try validatedPath(path)
case .remove(let program):
guard proposed.removeValue(forKey: program) != nil else {
throw PluginMappingError.nameMissing
}
case .rename(let program, let newProgram, let path):
try rename(program, newProgram: newProgram, path: path, mappings: &proposed)
}
}
guard proposed != captured.mappings else { return }
try publishAfterSaving(proposed)
}
private func rename(
_ program: String, newProgram: String, path: String, mappings: inout [String: String]
) throws {
let name = try validatedName(newProgram)
guard mappings[program] != nil else { throw PluginMappingError.nameMissing }
guard name == program || mappings[name] == nil else { throw PluginMappingError.nameExists }
let path = try validatedPath(path)
mappings.removeValue(forKey: program)
mappings[name] = path
}
/// Explicit repair; a corrupt store never silently becomes an empty override set.
@MainActor
func replaceMappings(_ mappings: [String: String]) throws {
var proposed: [String: String] = [:]
for (program, path) in mappings {
let name = try validatedName(program)
guard proposed[name] == nil else { throw PluginMappingError.nameExists }
proposed[name] = try validatedPath(path)
}
try publishAfterSaving(proposed)
}
@MainActor
private func publishAfterSaving(_ proposed: [String: String]) throws {
try store.save(proposed)
objectWillChange.send()
let generation = lock.withLock {
state.mappings = proposed
state.unreadable = false
state.generation += 1
return state.generation
}
invalidateRuntime?()
revalidate?()
didCommit.send(generation)
refreshSecurityFacts()
if let converge {
Task { @MainActor [weak self] in
guard let self, self.catalogSnapshot().generation == generation else { return }
await converge()
}
}
}
/// Explicit refresh also serves activation and startup failure diagnostics.
@discardableResult
@MainActor
func refreshSecurityFacts() -> Task<Void, Never> {
for task in inspectionTasks.values { task.cancel() }
inspectionTasks.removeAll()
securityFacts.removeAll()
let snapshot = catalogSnapshot()
for entry in snapshot.entries where entry.source == .user {
guard let path = entry.path, let identity = PluginFileIdentity(path: path) else { continue }
let inspector = inspector
inspectionTasks[entry.program] = Task { @MainActor [weak self] in
guard !Task.isCancelled else { return }
let facts = await inspector.inspect(URL(fileURLWithPath: path))
guard let self, !Task.isCancelled,
self.catalogSnapshot().generation == snapshot.generation,
self.catalogSnapshot().entry(for: entry.program)?.path == path,
PluginFileIdentity(path: path) == identity
else { return }
self.securityFacts[entry.program] = facts
self.inspectionTasks.removeValue(forKey: entry.program)
}
}
let tasks = Array(inspectionTasks.values)
return Task { @MainActor in
for task in tasks { await task.value }
}
}
private func validatedName(_ program: String) throws -> String {
let name = program.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !name.contains("\0") else { throw PluginMappingError.invalidName }
return name
}
private func validatedPath(_ path: String) throws -> String {
guard path.hasPrefix("/"), !path.contains("\0") else { throw PluginMappingError.invalidPath }
var isDirectory: ObjCBool = false
guard managed.fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
!isDirectory.boolValue, managed.fileManager.isExecutableFile(atPath: path)
else { throw PluginMappingError.unavailableFile }
return path
}
}
@@ -0,0 +1,101 @@
import Darwin
import Foundation
/// One derivation uses captured executable facts, never a live mutable mapping.
struct PluginCatalogSnapshot: PluginExecutableResolving, Equatable, Sendable {
enum Source: String, Codable, Sendable {
case managed
case user
case unknown
}
enum Availability: Equatable, Sendable {
case available
case missing
case notExecutable
case unreadable
}
struct Entry: Equatable, Sendable {
let program: String
let path: String?
let source: Source
let availability: Availability
let managedInfo: ManagedPluginInfo?
}
let entries: [Entry]
let generation: Int
let mappingsUnreadable: Bool
init(
mappings: [String: String],
managed: BundleManagedPluginProvider = BundleManagedPluginProvider(),
generation: Int = 0,
mappingsUnreadable: Bool = false
) {
let programs = Set(ManagedPluginCatalog.plugins.map(\.program)).union(mappings.keys).sorted()
self.entries = programs.map { program in
let info = ManagedPluginCatalog.info(forProgram: program)
let source: Source =
mappingsUnreadable ? .unknown : (mappings[program] == nil ? .managed : .user)
let path =
mappings[program]
?? managed.bundleURL
.appendingPathComponent("Contents/Helpers/Plugins").appendingPathComponent(program).path
let availability: Availability
do {
let target = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
let attributes = try managed.fileManager.attributesOfItem(atPath: target)
if attributes[.type] as? FileAttributeType == .typeSymbolicLink {
// Foundation leaves broken or unresolvable links unchanged. stat follows
// the target and preserves the distinction between absence and read failure.
var facts = stat()
if stat(path, &facts) != 0 {
availability = errno == ENOENT || errno == ENOTDIR ? .missing : .unreadable
} else {
availability =
(facts.st_mode & S_IFMT) == S_IFDIR
|| !managed.fileManager.isExecutableFile(atPath: path) ? .notExecutable : .available
}
} else if attributes[.type] as? FileAttributeType == .typeDirectory
|| !managed.fileManager.isExecutableFile(atPath: path)
{
availability = .notExecutable
} else {
availability = .available
}
} catch {
let failure = error as NSError
availability =
failure.domain == NSCocoaErrorDomain
&& failure.code == NSFileReadNoSuchFileError ? .missing : .unreadable
}
return Entry(
program: program, path: path, source: source,
availability: mappingsUnreadable ? .unreadable : availability,
managedInfo: source == .managed ? info : nil)
}
self.generation = generation
self.mappingsUnreadable = mappingsUnreadable
}
init(entries: [Entry], generation: Int = 0, mappingsUnreadable: Bool = false) {
self.entries = entries
self.generation = generation
self.mappingsUnreadable = mappingsUnreadable
}
func entry(for program: String) -> Entry? {
entries.first { $0.program == program }
}
func executablePath(forProgram program: String) -> String? {
guard !mappingsUnreadable, let entry = entry(for: program),
entry.availability == .available
else { return nil }
return entry.path
}
func catalogSnapshot() -> PluginCatalogSnapshot { self }
}
@@ -0,0 +1,57 @@
import Foundation
protocol PluginMappingStoring {
func load() throws -> [String: String]
func save(_ mappings: [String: String]) throws
}
enum PluginMappingError: Error, Equatable {
case unreadable
case unsupportedVersion
case invalidName
case invalidPath
case unavailableFile
case nameExists
case nameMissing
}
struct PluginMappingFileStore: PluginMappingStoring {
var fileURL = RuntimePaths.runtimeDirectory().appendingPathComponent("plugins.json")
private struct Record: Codable {
let version: Int
let mappings: [String: String]
}
func load() throws -> [String: String] {
// A missing file is empty; an unreadable or malformed file must not erase overrides.
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch let error as CocoaError where error.code == .fileReadNoSuchFile {
return [:]
} catch {
throw PluginMappingError.unreadable
}
let record: Record
do {
record = try JSONDecoder().decode(Record.self, from: data)
} catch {
throw PluginMappingError.unreadable
}
guard record.version == 1 else { throw PluginMappingError.unsupportedVersion }
for (name, path) in record.mappings {
guard name == name.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty,
!name.contains("\0"), path.hasPrefix("/"), !path.contains("\0")
else { throw PluginMappingError.unreadable }
}
return record.mappings
}
func save(_ mappings: [String: String]) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(
try encoder.encode(Record(version: 1, mappings: mappings)), to: fileURL)
}
}
@@ -28,7 +28,7 @@ struct ServerValidation: Equatable, Sendable {
static func evaluate(
_ fields: ServerFields,
credentials: CredentialStoring,
plugins: ManagedPluginProviding
plugins: PluginExecutableResolving
) -> ServerValidation {
var issues: [LeafInvalidationReason] = []
appendAddressIssue(fields.address, to: &issues)
@@ -77,7 +77,7 @@ struct ServerValidation: Equatable, Sendable {
private static func appendPluginIssues(
_ fields: ServerFields,
credentials: CredentialStoring,
plugins: ManagedPluginProviding,
plugins: PluginExecutableResolving,
to issues: inout [LeafInvalidationReason]
) {
guard let program = fields.pluginProgram else { return }
+8
View File
@@ -98,3 +98,11 @@ Python 是唯一的上游转换实现;Swift 只加载、校验并消费快照
## 依赖升级
升级任一外部二进制是显式供应链动作:人工复验新 release(下载归档、静态观测哈希/架构/签名,不执行),随后在一次 PR 中同时改 `Vendor/<name>/manifest.json` 的 release/asset/url/archiveSHA256 与对应测试锚点。不接受 `latest`,不在仓库内构建,不做应用内更新通道(升级仅随 app 发版)。
### 用户插件目录
`PluginCatalog` 合成当前托管清单与本机 `plugins.json` 用户映射,名称精确区分大小写。用户映射优先,失效 override 不退回托管程序;删除 override 恢复当前 app 默认值。文件读取错误保留阻断事实,不按空映射处理。用户路径不进入服务器资料、订阅或分享文件,也不进入诊断报告。
当前实现提供映射提交与显式修复 interface,并在组合根统一接入表单、激活与诊断;管理 UI 留待后续实现。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。
`MacOSPluginInspection` 只读取下载隔离、签名与系统评估事实,不启动用户插件、不修改系统策略。检查失败或不适用为未知;策略评估拒绝不等于实际执行禁令,也不阻止映射保存或激活。真实 macOS 拦截提示不由 hostless 替身测试证明。
@@ -6,7 +6,7 @@ import XCTest
/// 激活与重展开的便捷调用、原子失败断言。
enum ActivationFixture {
/// 受管插件测试集:只提供表内程序,值即写入文档的 bundle 内路径。
struct TestManagedPlugins: ManagedPluginProviding {
struct TestManagedPlugins: PluginExecutableResolving {
let pathsByProgram: [String: String]
func executablePath(forProgram program: String) -> String? {
+1 -1
View File
@@ -146,7 +146,7 @@ final class InMemoryLegacyImportMarker: LegacyImportMarkerStoring {
func makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator,
credentials: CredentialStoring,
plugins: ManagedPluginProviding = NoManagedPluginProvider(),
plugins: PluginExecutableResolving = NoManagedPluginProvider(),
subscriptionFetcher: SubscriptionFetching = FakeSubscriptionFetcher(
behavior: .success(Data())),
legacyImportService: LegacyImportService? = nil,
@@ -90,11 +90,11 @@ final class CatalogWorkflowCreateServerTests: XCTestCase {
let id = try await workflow.createServer(
ServerEditDraft(
address: "203.0.113.10", port: 8388, encryptionMethod: "aes-256-gcm",
password: "密码", remark: "插件服务器", plugin: .managed(program: "v2ray-plugin"),
password: "密码", remark: "插件服务器", plugin: .named(program: "v2ray-plugin"),
pluginOptions: "mode=websocket;host=example.com"),
into: nil)
let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: "v2ray-plugin"))
XCTAssertEqual(plugin.selection, .named(program: "v2ray-plugin"))
XCTAssertTrue(plugin.optionsPresent)
XCTAssertEqual(plugin.options, "mode=websocket;host=example.com")
}
@@ -120,8 +120,8 @@ final class CatalogWorkflowCreateServerTests: XCTestCase {
.unsupportedEncryptionMethod("future-cipher")),
InvalidCreateDraftCase("空密码", draft(password: ""), .invalidPassword),
InvalidCreateDraftCase(
"受管集外插件", draft(plugin: .managed(program: "future-plugin")),
.pluginNotManaged("future-plugin")),
"受管集外插件", draft(plugin: .named(program: "future-plugin")),
.pluginUnknown("future-plugin")),
]
}
}
@@ -95,7 +95,7 @@ final class CatalogWorkflowPluginTests: XCTestCase {
try writePluginBinary()
let id = try await addPluginServer(program: pluginBinaryName, options: "mode=websocket")
let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: pluginBinaryName))
XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName))
XCTAssertTrue(plugin.provided, "二进制在位即提供")
XCTAssertTrue(plugin.optionsPresent)
XCTAssertEqual(plugin.options, "mode=websocket", "参数明文供输入框预填")
@@ -105,7 +105,7 @@ final class CatalogWorkflowPluginTests: XCTestCase {
func testMissingBinaryShowsManagedButNotProvided() async throws {
let id = try await addPluginServer(program: pluginBinaryName, options: nil)
let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: pluginBinaryName))
XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName))
XCTAssertFalse(plugin.provided, "打包损坏/降级丢插件时点名呈现")
}
@@ -132,10 +132,10 @@ final class CatalogWorkflowPluginTests: XCTestCase {
try writePluginBinary()
let id = try await addPluginServer(program: nil, options: nil)
try await update(
id, plugin: .managed(program: pluginBinaryName),
id, plugin: .named(program: pluginBinaryName),
pluginOptions: "mode=websocket;host=example.com")
let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: pluginBinaryName))
XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName))
XCTAssertEqual(plugin.options, "mode=websocket;host=example.com")
XCTAssertTrue(plugin.optionsPresent)
}
@@ -143,9 +143,9 @@ final class CatalogWorkflowPluginTests: XCTestCase {
func testSavingEmptyOptionsDropsOptions() async throws {
try writePluginBinary()
let id = try await addPluginServer(program: pluginBinaryName, options: "mode=websocket")
try await update(id, plugin: .managed(program: pluginBinaryName), pluginOptions: " ")
try await update(id, plugin: .named(program: pluginBinaryName), pluginOptions: " ")
let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: pluginBinaryName), "程序引用保留")
XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName), "程序引用保留")
XCTAssertFalse(plugin.optionsPresent, "空参数即无参数引用")
XCTAssertEqual(plugin.options, "")
// 秘密确实从凭据存储删除(引用固定为夹具常量)。
@@ -180,10 +180,10 @@ final class CatalogWorkflowPluginTests: XCTestCase {
let id = try await addPluginServer(program: nil, options: nil)
await expectThrowsAsync(
{
try await update(id, plugin: .managed(program: "obfs-local"), pluginOptions: nil)
try await update(id, plugin: .named(program: "obfs-local"), pluginOptions: nil)
},
onThrow: { error in
XCTAssertEqual(error as? ServerFormError, .pluginNotManaged("obfs-local"))
XCTAssertEqual(error as? ServerFormError, .pluginUnknown("obfs-local"))
})
}
@@ -193,7 +193,7 @@ final class CatalogWorkflowPluginTests: XCTestCase {
try writePluginBinary()
let id = try await addPluginServer(program: nil, options: nil)
try await update(
id, plugin: .managed(program: pluginBinaryName),
id, plugin: .named(program: pluginBinaryName),
pluginOptions: "mode=websocket;host=example.com")
let shared = try workflow.shareURI(for: id)
@@ -207,7 +207,7 @@ final class CatalogWorkflowPluginTests: XCTestCase {
let importedNode = try XCTUnwrap(
workflow.tree.roots.dropFirst().first, "第二个根节点应是新导入的服务器")
let imported = try XCTUnwrap(try workflow.serverEditForm(for: importedNode.id))
XCTAssertEqual(imported.plugin.selection, .managed(program: pluginBinaryName))
XCTAssertEqual(imported.plugin.selection, .named(program: pluginBinaryName))
XCTAssertEqual(imported.plugin.options, "mode=websocket;host=example.com")
}
}
@@ -148,7 +148,7 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
}
let form = try XCTUnwrap(try workflow.serverEditForm(for: id))
XCTAssertEqual(form.plugin.selection, .managed(program: "v2ray-plugin"), "插件引用保留")
XCTAssertEqual(form.plugin.selection, .named(program: "v2ray-plugin"), "插件引用保留")
XCTAssertEqual(form.plugin.options, "mode=websocket", "被删除的参数秘密已恢复")
}
@@ -78,7 +78,7 @@ final class CatalogWorkflowTests: XCTestCase {
_ = await workflow.importServers(from: [.clipboardText(uri)], into: nil)
let id = try XCTUnwrap(workflow.tree.roots.first?.id)
let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: "v2ray-plugin"))
XCTAssertEqual(plugin.selection, .named(program: "v2ray-plugin"))
XCTAssertTrue(plugin.optionsPresent)
// NoManagedPluginProvider:程序引用保留但可执行文件缺失(集外点名事实)。
XCTAssertFalse(plugin.provided)
@@ -0,0 +1,374 @@
import Foundation
import Testing
import XCTest
@testable import ShadowsocksX_NG2
private struct QuietPluginInspector: PluginInspecting {
func inspect(_ executable: URL) async -> PluginSecurityFacts {
PluginSecurityFacts(quarantine: .absent, signature: .notApplicable, policy: .notApplicable)
}
}
struct PluginCatalogTests {
@Test func invalidOverrideDoesNotUseManagedExecutable() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let binary = root.appendingPathComponent("Contents/Helpers/Plugins/v2ray-plugin")
try FileManager.default.createDirectory(
at: binary.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let snapshot = PluginCatalogSnapshot(
mappings: ["v2ray-plugin": root.appendingPathComponent("missing").path],
managed: BundleManagedPluginProvider(bundleURL: root))
#expect(snapshot.executablePath(forProgram: "v2ray-plugin") == nil)
#expect(snapshot.entry(for: "v2ray-plugin")?.source == .user)
}
@Test @MainActor func savedMappingIsUsedAndRemovalRemovesCustomName() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let binary = root.appendingPathComponent("custom-plugin")
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json"))
let catalog = PluginCatalog(store: store, inspector: QuietPluginInspector())
try catalog.commit([.add(program: "custom-plugin", path: binary.path)])
#expect(catalog.catalogSnapshot().executablePath(forProgram: "custom-plugin") == binary.path)
#expect(
PluginCatalog(store: store, inspector: QuietPluginInspector()).catalogSnapshot().entry(
for: "custom-plugin")?.source == .user)
try catalog.commit([.remove(program: "custom-plugin")])
#expect(catalog.catalogSnapshot().entry(for: "custom-plugin") == nil)
}
@Test @MainActor func userPluginCanBeSavedThroughServerFormInterface() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let binary = root.appendingPathComponent("custom-plugin")
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let plugins = PluginCatalog(
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
inspector: QuietPluginInspector())
try plugins.commit([.add(program: "custom-plugin", path: binary.path)])
let workflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: root.appendingPathComponent("catalog.json")),
runtime: FakeCatalogRuntime()),
credentials: InMemoryCredentialStore(), plugins: plugins)
let id = try await workflow.createServer(
ServerEditDraft(
address: "127.0.0.1", port: 8388, encryptionMethod: "aes-256-gcm", password: "pw",
remark: "Custom", plugin: .named(program: "custom-plugin"), pluginOptions: "flag"),
into: nil)
#expect(try workflow.serverEditForm(for: id)?.plugin.options == "flag")
}
@Test @MainActor func commitPublishesBeforeSchedulingRuntimeApplication() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let binary = root.appendingPathComponent("custom-plugin")
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let catalog = PluginCatalog(
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
inspector: QuietPluginInspector())
var events: [String] = []
let applied = AsyncStream<Void>.makeStream()
catalog.connect(
invalidateRuntime: { events.append("invalidate") },
revalidate: { events.append("publish") },
converge: {
events.append("apply")
applied.continuation.yield(())
})
try catalog.commit([.add(program: "custom-plugin", path: binary.path)])
#expect(events == ["invalidate", "publish"])
for await _ in applied.stream { break }
#expect(events == ["invalidate", "publish", "apply"])
}
@Test @MainActor func corruptionBlocksManagedResolutionUntilExplicitRepair() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let binary = root.appendingPathComponent("Contents/Helpers/Plugins/v2ray-plugin")
try FileManager.default.createDirectory(
at: binary.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let file = root.appendingPathComponent("plugins.json")
try Data("broken".utf8).write(to: file)
let catalog = PluginCatalog(
store: PluginMappingFileStore(fileURL: file),
managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector())
#expect(catalog.catalogSnapshot().mappingsUnreadable)
#expect(catalog.executablePath(forProgram: "v2ray-plugin") == nil)
#expect(catalog.catalogSnapshot().entry(for: "v2ray-plugin")?.source == .unknown)
#expect(throws: PluginMappingError.unreadable) { try catalog.commit([]) }
try catalog.replaceMappings([:])
#expect(catalog.executablePath(forProgram: "v2ray-plugin") == binary.path)
}
@Test @MainActor func failedPersistenceDoesNotPublishOrInvalidateRuntime() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let binary = root.appendingPathComponent("tool")
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let catalog = PluginCatalog(store: FailingMappingStore(), inspector: QuietPluginInspector())
var published = false
catalog.connect(
invalidateRuntime: { published = true }, revalidate: { published = true }, converge: {})
#expect(throws: PluginMappingError.unreadable) {
try catalog.commit([.add(program: "tool", path: binary.path)])
}
#expect(!published)
#expect(catalog.catalogSnapshot().generation == 0)
#expect(catalog.catalogSnapshot().entry(for: "tool") == nil)
}
@Test @MainActor func symlinkAndCaseSensitiveNamesSurviveAtomicRename() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let binary = root.appendingPathComponent("tool")
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let link = root.appendingPathComponent("link")
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: binary)
let catalog = PluginCatalog(
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
inspector: QuietPluginInspector())
try catalog.commit([.add(program: " Tool ", path: link.path)])
#expect(catalog.executablePath(forProgram: "Tool") == link.path)
#expect(catalog.executablePath(forProgram: "tool") == nil)
let captured = catalog.catalogSnapshot()
try catalog.commit([.rename(program: "Tool", newProgram: "Other", path: link.path)])
#expect(catalog.executablePath(forProgram: "Tool") == nil)
#expect(captured.executablePath(forProgram: "Tool") == link.path)
try FileManager.default.removeItem(at: binary)
#expect(catalog.executablePath(forProgram: "Other") == nil)
try catalog.commit([.remove(program: "Other")])
#expect(catalog.catalogSnapshot().entry(for: "Other") == nil)
}
@Test @MainActor func mappingAdoptionRefreshesOnlyPluginDraft() throws {
let id = NodeID.fresh()
let fields = ServerFormFields()
var plugin = PluginSectionState(
selection: .unknown(program: "tool"), managed: [], provided: false,
optionsPresent: true, options: "")
let original = ServerEditForm(
address: "127.0.0.1", port: 8388, encryptionMethod: "aes-256-gcm", password: "pw",
remark: "Original", plugin: plugin, isEditable: true)
fields.showServer(id) { _ in original }
fields.remark = "Unsaved name"
plugin = PluginSectionState(
selection: .named(program: "tool"), managed: [], provided: true,
optionsPresent: true, options: "flag", programs: ["tool"])
let mapped = ServerEditForm(
address: original.address, port: original.port, encryptionMethod: original.encryptionMethod,
password: original.password, remark: original.remark, plugin: plugin, isEditable: true)
fields.updatePresentation(
ServerFormPresentation(isEditable: true, plugin: plugin), load: { _ in mapped })
#expect(fields.remark == "Unsaved name")
#expect(fields.pluginChoice == .named(program: "tool"))
#expect(fields.pluginOptions.composedString == "flag")
#expect(fields.hasChanges)
}
@Test @MainActor func changedMappingSupersedesInFlightRuntimeHealthCheck() async throws {
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
defer { try? FileManager.default.removeItem(at: runtime.directory) }
let first = runtime.directory.appendingPathComponent("first-plugin")
let second = runtime.directory.appendingPathComponent("second-plugin")
for binary in [first, second] {
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
}
let plugins = PluginCatalog(
store: PluginMappingFileStore(
fileURL: runtime.directory.appendingPathComponent("plugins.json")),
inspector: QuietPluginInspector())
try plugins.commit([.add(program: "tool", path: first.path)])
let credentials = InMemoryCredentialStore()
var catalog = ConfigurationCatalog()
let id = try ActivationFixture.addPlainServer("Plugin", in: &catalog, credentials: credentials)
var fields = try #require(catalog.entry(for: id)).serverFieldsForPluginTest
fields.pluginProgram = "tool"
try catalog.updateServer(id, with: fields)
let catalogFile = runtime.directory.appendingPathComponent("catalog.json")
try CatalogFileStore(fileURL: catalogFile).save(CatalogDocument(catalog: catalog))
let agent = ProxyRuntimeFixture.FakeLaunchAgent()
let files = RuntimeFileStore(fileURL: runtime.contract)
agent.onRegister = {
if let document = files.loadDocument() {
try? files.writeRuntimeReceipt(for: document, processID: 42)
}
}
let probe = ProxyRuntimeFixture.BlockingProbe()
let controller = ProxyRuntimeController(
catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFile),
activationFileStore: ActivationStateFileStore(
fileURL: runtime.directory.appendingPathComponent("activation.json")),
runtimeFileStore: files, credentials: credentials, plugins: plugins,
listenRestore: RestoredListenSettings(
settings: ActivationFixture.listen, unreadableError: nil),
settingsStore: InMemoryProxySettingsStore(),
customRuleStore: CustomRuleStore(
fileURL: runtime.directory.appendingPathComponent("rules.json")),
appBundle: AppArtifact.bundle,
ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot),
settingsRestore: RestoredProxySettings(
settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: true),
unreadableError: nil),
agent: agent, probe: probe, systemProxy: ProxyRuntimeFixture.FakeSystemProxy(),
systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(),
firewallChecker: ProxyRuntimeFixture.FakeFirewallChecker(),
sendSignal: { _, _ in 0 }, processIsAlive: { $0 == 42 })
let started = XCTestExpectation(description: "old runtime entered health check")
probe.arm(started: started)
let oldActivation = Task { try await controller.activate(id) }
let wait = await XCTWaiter.fulfillment(of: [started], timeout: 3)
#expect(wait == .completed)
let applied = AsyncStream<Void>.makeStream()
plugins.connect(
invalidateRuntime: { controller.pluginMappingsDidPublish() }, revalidate: {},
converge: {
_ = await controller.catalogDidCommit(snapshot: catalog)
applied.continuation.yield(())
})
try plugins.commit([.update(program: "tool", path: second.path)])
probe.release()
_ = try await oldActivation.value
for await _ in applied.stream { break }
#expect(files.loadDocument()?.servers.first?.plugin == second.path)
#expect(controller.state == .running)
}
}
struct PluginCatalogSecurityTests {
@Test(arguments: ["unchanged", "replace", "remove", "permissions"])
@MainActor func inspectionEvidenceFollowsCurrentMappingAndFile(change: String) async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let binary = root.appendingPathComponent("tool")
try Data("#!/bin/sh\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
let inspector = HeldPluginInspector()
let catalog = PluginCatalog(
store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")),
inspector: inspector)
try catalog.commit([.add(program: "tool", path: binary.path)])
let check = catalog.refreshSecurityFacts()
var started = inspector.started.makeAsyncIterator()
_ = await started.next()
if change == "replace" {
try Data("#!/bin/sh\n# replacement\n".utf8).write(to: binary, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
} else if change == "permissions" {
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: binary.path)
} else if change == "remove" {
try catalog.commit([.remove(program: "tool")])
}
await inspector.release()
await check.value
if change == "unchanged" {
#expect(catalog.securityFacts["tool"]?.policy == .rejected)
#expect(catalog.executablePath(forProgram: "tool") == binary.path)
} else {
#expect(catalog.securityFacts["tool"] == nil)
#expect(
catalog.executablePath(forProgram: "tool")
== (["remove", "permissions"].contains(change) ? nil : binary.path))
}
}
@Test func symlinkTargetsMustBeExecutableFiles() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let directoryLink = root.appendingPathComponent("directory-link")
let missingLink = root.appendingPathComponent("missing-link")
try FileManager.default.createSymbolicLink(at: directoryLink, withDestinationURL: root)
try FileManager.default.createSymbolicLink(
at: missingLink, withDestinationURL: root.appendingPathComponent("missing"))
let snapshot = PluginCatalogSnapshot(
mappings: ["directory": directoryLink.path, "missing": missingLink.path])
#expect(snapshot.entry(for: "directory")?.availability == .notExecutable)
#expect(snapshot.entry(for: "missing")?.availability == .missing)
#expect(snapshot.executablePath(forProgram: "directory") == nil)
#expect(snapshot.executablePath(forProgram: "missing") == nil)
}
@Test func failedFileInspectionIsDistinctFromMissingFile() {
let snapshot = PluginCatalogSnapshot(
mappings: ["tool": "/unreadable/tool"],
managed: BundleManagedPluginProvider(fileManager: UnreadablePluginFileManager()))
#expect(snapshot.entry(for: "tool")?.availability == .unreadable)
#expect(snapshot.executablePath(forProgram: "tool") == nil)
}
@Test func userDiagnosticFactsNeverInheritManagedVersion() {
var snapshot = DiagnosticSnapshot()
snapshot.managedPlugins = [
DiagnosticPluginFacts(
program: "v2ray-plugin", version: "v1.3.2", present: true, source: .user)
]
let report = DiagnosticReportBuilder.markdown(from: snapshot)
#expect(report.contains("v2ray-plugin user"))
#expect(!report.contains("v1.3.2"))
}
}
private struct FailingMappingStore: PluginMappingStoring {
func load() throws -> [String: String] { [:] }
func save(_ mappings: [String: String]) throws { throw PluginMappingError.unreadable }
}
extension CatalogEntry {
fileprivate var serverFieldsForPluginTest: ServerFields {
guard case .server(let fields) = kind else { preconditionFailure("expected server") }
return fields
}
}
private actor HeldPluginInspector: PluginInspecting {
nonisolated let started: AsyncStream<Void>
private let signal: AsyncStream<Void>.Continuation
private var pending: CheckedContinuation<PluginSecurityFacts, Never>?
init() {
let stream = AsyncStream<Void>.makeStream()
started = stream.stream
signal = stream.continuation
}
func inspect(_ executable: URL) async -> PluginSecurityFacts {
await withCheckedContinuation { continuation in
pending = continuation
signal.yield(())
}
}
func release() {
pending?.resume(
returning: PluginSecurityFacts(
quarantine: .present, signature: .unsigned, policy: .rejected))
pending = nil
}
}
private final class UnreadablePluginFileManager: FileManager, @unchecked Sendable {
override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
}
@@ -0,0 +1,24 @@
import Foundation
import Testing
@testable import ShadowsocksX_NG2
struct PluginSecurityInspectionTests {
@Test func commandLineAssessmentContextIsNotReportedAsExecutionBlock() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let binary = root.appendingPathComponent("tool")
try Data([0xcf, 0xfa, 0xed, 0xfe]).write(to: binary)
let checker = MacOSPluginInspection { tool, _ in
if tool.lastPathComponent == "codesign" {
return PluginInspectionCommandResult(status: 0, output: "valid on disk")
}
return PluginInspectionCommandResult(
status: 3, output: "tool: rejected (the code is valid but does not seem to be an app)")
}
let facts = await checker.inspect(binary)
#expect(facts.signature == .valid)
#expect(facts.policy == .notApplicable)
}
}
@@ -29,7 +29,7 @@ struct ServerFormFieldLimitsTests {
password: password,
remark: name,
plugin: PluginSectionState(
selection: .managed(program: "v2ray-plugin"),
selection: .named(program: "v2ray-plugin"),
managed: ManagedPluginCatalog.plugins,
provided: true,
optionsPresent: !pluginOptions.isEmpty,
@@ -126,7 +126,7 @@ struct ServerFormLoadingTests {
let id = try await workflow.createServer(
ServerEditDraft(
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
password: "pw", remark: "服务器", plugin: .managed(program: program),
password: "pw", remark: "服务器", plugin: .named(program: program),
pluginOptions: "mode=websocket"), into: nil)
credentials.reads = 0
credentials.failReads = true
@@ -18,7 +18,7 @@ struct ServerFormPluginOptionsTests {
password: "password",
remark: "服务器",
plugin: PluginSectionState(
selection: .managed(program: "v2ray-plugin"),
selection: .named(program: "v2ray-plugin"),
managed: ManagedPluginCatalog.plugins,
provided: true,
optionsPresent: !pluginOptions.isEmpty,
+1 -1
View File
@@ -89,7 +89,7 @@ final class CatalogWorkflowArchitectureTests: XCTestCase {
"CatalogWorkflowDependencies", "CatalogCommitCoordinator", "CatalogFileStore",
"ConfigurationCatalog", "CatalogEntry", "CommittedCatalogSnapshot",
"CredentialStoring", "KeychainCredentialStore", "CredentialWriteJournal",
"CredentialReference", "ManagedPluginProviding", "BundleManagedPluginProvider",
"CredentialReference", "PluginExecutableResolving", "BundleManagedPluginProvider",
"SubscriptionFetching", "HTTPSSubscriptionFetcher", "SubscriptionRecord",
"LegacyImportService", "LegacySnapshot", "Activating", "ServerFields",
]
+139
View File
@@ -0,0 +1,139 @@
# 有效插件目录:实现规格
日期:2026-10-04。
状态:候选 1 的领域规则已确认并授权实现。当前交付范围为目录、持久化、接线与安全检查事实;用户明确选择管理 UI 后续实现。候选 2 的表单接入与候选 3 的提交接入是功能落地所需的相邻工作,不表示通用架构重构。此文记录本任务规格,当前实现仍以代码为准。
## 已确认的行为
- 用户维护当前 macOS 用户的本机插件名称到可执行文件路径的映射;只持久化用户新增与 override,不复制 managed 默认值。
- 名称区分大小写、精确匹配。新增名称去除首尾空白并拒绝空名称;导入的名称引用保持原样。
- 用户映射优先于同名托管插件。override 不可用仍阻断该名称,不静默退回 managed;删除 override 恢复当前 app 的 managed 默认值。
- 路径必须为绝对文件路径,允许符号链接。保存新增或修改的路径时要求目标当前存在且可执行;不运行插件,不解析 shell 命令、PATH 查找、相对路径或带参数的命令。
- 用户映射的有效来源不继承 managed 文件的版本、许可证或签名声明。
- 改名视为删除旧映射、添加新映射,不改写服务器配置与订阅。路径修改影响引用同一名称的所有服务器,包括订阅服务器。
- 保存后发布有效目录、刷新激活候选并异步收敛。运行失败不撤销已保存映射。
- 不监控同路径二进制替换,不据此自动重启。新的激活或代理启动重新检查文件;用户可关闭再开启代理使用同路径的新文件。
- 安全检查异步提供隔离标记、签名与策略评估事实;不因这些事实拒绝保存或激活,不自动移除隔离标记、重签或修改系统策略。检查失败或不适用不等于安全或禁止运行。
## 当前代码的摩擦
| 当前位置 | 当前事实 | 需要改变的内容 |
| --- | --- | --- |
| `Domain/ManagedPluginProviding.swift` | 名称到路径的现有 seam 已被校验和激活使用;bundle adapter 检查文件可执行性 | 保留窄解析 seam,托管供应链事实不承担整个有效目录的职责 |
| `Domain/ActivationStateMachine.swift` | 校验和生成运行条目分别调用 provider | 同一次派生读取同一个捕获快照,避免两次查询读到不同映射或文件检查事实 |
| `App/CatalogWorkflow/CatalogWorkflow.swift` | 直接以静态 managed 集合分类、加载参数和校验保存 | 改为有效目录事实;未知引用仍可保留,已映射引用可编辑参数 |
| `App/CatalogWorkflow/CatalogWorkflow+URI.swift` | `.unknown` 不更新插件引用及选项 | 不再用托管身份代表可编辑性;保留未解析引用的无损编辑行为 |
| `App/MainApp.swift`、`App/ProxyRuntimeController.swift`、`App/DiagnosticsWorkflow/DiagnosticsWorkflow.swift` | 生产读取者独立构造 bundle provider | 组合根装配一次插件目录 module,并向各读取者提供相同已提交映射来源 |
| `App/CatalogCommitCoordinator.swift` | 捕获服务器目录快照;只在目录提交时安排同步 | 映射变更有自己的保存入口;复用运行时,不伪造服务器目录提交 |
| `App/ProxyRuntimeController+Convergence.swift` | preparation 代次使旧派生失效;运行配置字节不变可跳过部署 | 发布映射时使旧插件派生失效,保留有效值不变的幂等行为 |
## Module 与 interface 提案
以下名称为拟议代码标识符;实际文件布局可随实现收敛。目标是让覆盖、文件事实与提交时序获得 locality,而非给每条规则创建 shallow module。
### 有效插件目录 module
拟议 `PluginCatalog` 是组合根拥有的单一 module。只向调用者提供:
1. 捕获当前有效目录快照,供表单、校验、激活及诊断读取。
2. 保存一组明确的用户映射变更,包括新增、更新、删除与改名组合。
3. 观察已提交目录变更及安全检查事实。
调用者不自行合并 managed 与用户映射,不直接改磁盘文件,也不拼接保存、刷新 projection 和运行重启步骤。存储与 macOS 检查 adapter 在 module 内接线;生产文件 adapter 和测试确定性 adapter 构成实际 seam。
拟议 `PluginCatalogSnapshot` 是只读值,包含已提交映射代次与各已知名称的有效来源、路径可用性及独立的托管发布事实。快照不表示文件永久可执行,也不表示远端连接、SIP003 兼容性或系统许可得到保证。
名称到路径的 interface 仍保持一个查询。建议把 `ManagedPluginProviding` 一次性改名为 `PluginExecutableResolving`,并让快照满足该 interface;不长期保留一层兼容转发 module。已有 `NoManagedPluginProvider` 与测试 provider 按同一 seam 调整。
解析结果由有效目录保留原因事实:未映射、文件缺失、非可执行文件、文件检查失败。现有激活 interface 可以继续把失败归入本地插件不可用原因;细化原因不要求改动 sslocal 的运行配置结构。
### 快照与文件事实
- 捕获快照时对已知名称的有效路径检查一次;查询仅查捕获结果。校验与运行条目生成共享该快照。
- 已有快照用于一次 projection 或派生,不缓存成整个会话的永久可用性结论。
- 新激活、代理启动和映射发布后的重校验捕获新快照;只检查有效路径,不因为用户 override 失效而检查并选用 managed 路径。
- 符号链接保存用户选定的路径;文件检查跟随链接,不能用链接本身的存在性替代目标的可执行性。
- “存在且可执行”还必须排除目录;路径作为进程参数或系统调用参数传递,不能拼入 shell 命令。
- 不通过快照承诺消除文件检查与实际执行之间的文件变化;最终执行结果仍属于运行时事实。
## 持久化与提交
建议增加独立 `plugins.json`,放在既有用户配置目录,通过 `AtomicFileWriter` 原子写入。文件只包含格式版本和用户名称—路径条目;managed 清单仍随 app 发布。
一组变更作为一次保存单位:规范化并验证本次新增或修改条目 → 在已提交副本上应用变更 → 原子写盘 → 发布新映射代次 → 重建有效目录与服务器 projection → 安排异步运行收敛。
- 同名新增不得静默覆盖已有用户条目;更新已有条目必须由明确更新操作表达。对 managed 名称新增用户条目就是 override。
- 改名包含旧名删除和新名新增,作为一次原子变更;新名称冲突不允许悄悄覆盖另一用户条目。
- 只校验本次新增或修改路径。既有失效映射允许保留,也允许删除,不能因为另一个文件失效就阻止用户修复无关条目。
- 文件可执行性检查失败拒绝本次新增或修改;安全预检不延迟或决定保存成功。
- 存储失败不发布快照、不刷新 projection、不安排运行收敛。运行失败保留保存结果。
- 无变化保存可以不产生新代次;重新激活与重新开启代理仍检查当前文件。
读取异常的实现建议:文件不存在表示没有用户映射;文件损坏、不可读或未知格式版本不能伪装成空映射,因为这可能意外启用被覆盖的 managed 文件。应保留错误事实,阻断依赖插件的自动派生并允许用户显式修复;不使用插件的服务器不受此错误影响。此失败处理已由用户确认。
## 映射变更与运行状态收敛
- 映射发布立即让后续读取捕获新代次,并使旧的运行派生失效;仅在异步任务结束时忽略结果不足以保护旧运行契约写入。
- 复用 `ProxyRuntimeController` 现有 preparation 代次与收敛入口。映射、服务器目录、规则及代理意图变化仍受同一运行时失效检查约束,不能为插件另造一套独立进程部署逻辑。
- 最新保存发生在运行任务中途时,保存可完成;在途任务不能晚到写入旧路径,随后安排最新目录的收敛。
- 目录重校验使用最新已提交服务器目录和本次插件快照;不修改订阅字段、服务器身份、凭据引用或分享格式。
- 未受影响的活动目标不因新增无关名称而重启;有效运行配置不变时沿用现有幂等判断。
- 活动组仍可跳过失效成员;目标没有可用服务器时沿用现有目标失效处理,不引入静默服务器替代。
- 表单只更新插件事实,不重载并覆盖未保存的服务器字段或参数草稿。
## macOS 安全检查 adapter
拟议 `MacOSPluginInspection` 把系统检查输出转换成结构化事实,而非把命令输出直接呈现或导出。
- 文件下载隔离标记:存在、未发现、读取失败。
- 签名:有效、未签名、签名无效、检查不适用、无法判断。脚本及其解释器不等同于一个 Mach-O 文件,不能默认要求所有可执行文件都有 Developer ID 签名。
- 系统策略评估:接受、评估拒绝、不适用、检查失败。独立工具的评估上下文限制必须保留,不能把 `spctl` 的非零退出统一映射成实际执行禁令。
- 只读检查在后台执行,带超时与受控输出大小,不随每次视图重绘重复执行;无须新建定时轮询或文件监控。
- 结果绑定映射代次、名称和所检查的目标文件事实。映射或目标文件变化后,旧结果不得显示在新目标上;检查完成时重新核对相关事实,不能把文件身份未确认的结果当成当前结果。
- 保存后可安排检查;新的激活 / 启动检查及启动失败后的复查不得直接执行用户插件作为探测。
- 普通退出、超时或被 signal 终止不足以证明安全策略阻止。仅有明确证据才能显示确认阻止;证据不足显示启动失败和辅助检查事实。
- 插件由 sslocal 启动,现有 GUI / agent 不一定直接得到其系统执行拒绝原因;本轮不为了强行归因解析任意原始日志,也不修改上游 sslocal。
- 本机 UI 可显示路径并提供 Finder 定位;诊断报告仍不导出路径、原始检查输出或插件参数。用户插件没有自报版本验证,因此有效来源不能继承 managed 的版本声明。
依据(2026-10-04 查阅,属于外部能力资料,不表示已有实现):
- [Apple TN2206:签名验证与 Gatekeeper 的区别](https://developer.apple.com/library/archive/technotes/tn2206/)
- [Apple Code Signing Tasks:系统策略评估](https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html)
- [Apple:安全打开 Mac 软件](https://support.apple.com/en-nz/102445)
## 相邻 module 的接入范围
候选 2:调整 `PluginSelection` 与 `PluginSectionState`,让服务器引用与实际来源独立;读取有效目录的名称选择和可用性。未知引用无损保留;对应映射新增后加载原有参数;映射删除后不删除引用及钥匙串参数。保留现有 `PluginOptionsDraft` 与 `CredentialWriteJournal`。
候选 3:映射提交 module 负责保存、发布与安排收敛;复用现有原子写盘与运行时,不先建立跨所有设置的通用协调框架。具体管理页面位置、编辑交互与中英文提示文案尚未确认,不属于当前已确认的候选 1 领域合同。
## 验证矩阵
测试通过调用者使用的 interface 观察行为,新增 hostless 测试默认使用 Swift Testing;临时目录及文件只占用进程私有资源。
| 验证面 | 必须观察到的行为 |
| --- | --- |
| 有效目录 interface | managed 默认值、新名称、大小写区分、同名 override、删除恢复当前 managed、失效 override 不回退 |
| 文件 adapter | 普通可执行文件、目录拒绝、缺文件、权限丢失、符号链接可用及失效、文件检查失败 |
| 保存 interface | 路径规范与名称冲突、改名原子性、只校验改动条目、保存失败不发布、无变化幂等、重新加载恢复用户映射 |
| 读取异常 | 缺文件与损坏不同;损坏不使 managed override 消失;显式修复后恢复插件派生 |
| 服务器表单 interface | 用户插件创建 / 编辑、unknown 映射后参数可见、删除映射保留引用和参数、订阅仍只读、刷新不覆盖草稿 |
| 激活 interface | 快照路径进入运行配置、组跳过失效成员、无有效成员沿用失败处理、不修改分享与订阅内容 |
| 收敛 interface | 保存完成不等待运行健康;运行失败不撤销保存;A→B 连续保存旧运行写入受抑制;无关映射不重启;关闭代理期间旧任务不复活代理 |
| 检查 adapter 的确定性替身 | 隔离 / 未签名 / 拒绝为提示;不适用及失败不误报;超时不挂起保存;旧结果不覆盖新目标 |
| 诊断 interface | 来源与有效路径选择一致;用户文件不继承 managed 版本;路径与原始输出不进入报告 |
继续保留有价值的 bundle 文件检查与清单锚点测试;新增有效目录行为覆盖后,移除仅固化静态 managed 分类的重复断言,不叠加一套测试保留相互矛盾的旧合同。
hostless 测试不证明真实 Gatekeeper 行为。实现后需单独用本机受控夹具核实签名、隔离与独立工具评估的呈现;实际 sslocal 子进程、native UI 与系统安全提示分别报告,不能用 fake 检查通过替代。
## 实施顺序与审阅点
1. 有效目录值、用户映射模型、文件 adapter 与持久化;先证明解析优先级、文件事实和读取异常。
2. 在组合根统一注入;校验与激活捕获固定快照,诊断读取相同来源事实。
3. 表单引用接入与提交发布;证明异步过期任务不能写回旧运行配置。
4. macOS 检查 adapter 与提示事实;管理 UI 布局和本地化另行确认后接入。
5. 聚焦测试、既有相关回归、构建与独立本机证据。若新增工程结构,遵循 XcodeGen 的 `project.yml`,不手改 `.xcodeproj`。
本文没有引入插件下载 / 安装 / 升级、插件参数语义验证、文件监控、自动重签或系统安全策略修改;没有修改 frozen Legacy,也没有发布 GitHub Issue 或推送。实现按用户授权在当前分支作本地提交。