From 128efcc0d66648f0d2a85440ad56e605bb669bc0 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 5 Oct 2026 00:28:16 +0800 Subject: [PATCH] feat(plugins): add user plugin catalog and runtime wiring --- GLOSSARY.md | 8 +- .../CatalogWorkflow/CatalogWorkflow+URI.swift | 2 +- .../App/CatalogWorkflow/CatalogWorkflow.swift | 39 +- .../CatalogWorkflowDependencies.swift | 2 +- .../CatalogWorkflowModels.swift | 18 +- ShadowsocksX-NG2/App/ErrorPresentation.swift | 2 +- ShadowsocksX-NG2/App/MainApp.swift | 32 +- .../App/ProxyRuntimeController+Commands.swift | 7 +- .../ProxyRuntimeController+SettingsSync.swift | 5 + .../App/ProxyRuntimeController.swift | 7 +- ShadowsocksX-NG2/App/ServerDetailView.swift | 6 +- ShadowsocksX-NG2/App/ServerFormFields.swift | 39 +- .../App/ServerPluginSection.swift | 16 +- .../Domain/ActivationStateMachine.swift | 11 +- .../Domain/DiagnosticReport.swift | 5 +- .../Domain/MacOSPluginInspection.swift | 156 ++++++++ .../Domain/ManagedPluginProviding.swift | 27 +- ShadowsocksX-NG2/Domain/PluginCatalog.swift | 186 +++++++++ .../Domain/PluginCatalogSnapshot.swift | 101 +++++ .../Domain/PluginMappingFileStore.swift | 57 +++ .../Domain/ServerValidation.swift | 4 +- ShadowsocksX-NG2/README.md | 8 + .../Tests/ActivationFixture.swift | 2 +- ShadowsocksX-NG2/Tests/CatalogFixtures.swift | 2 +- .../CatalogWorkflowCreateServerTests.swift | 8 +- .../Tests/CatalogWorkflowPluginTests.swift | 20 +- .../Tests/CatalogWorkflowRollbackTests.swift | 2 +- .../Tests/CatalogWorkflowTests.swift | 2 +- .../Tests/PluginCatalogTests.swift | 374 ++++++++++++++++++ .../Tests/PluginSecurityInspectionTests.swift | 24 ++ .../Tests/ServerFormFieldLimitsTests.swift | 2 +- .../Tests/ServerFormLoadingTests.swift | 2 +- .../Tests/ServerFormPluginOptionsTests.swift | 2 +- ShadowsocksX-NG2/Tests/SkeletonTests.swift | 2 +- docs/design/user-plugin-catalog-spec.md | 139 +++++++ 35 files changed, 1231 insertions(+), 88 deletions(-) create mode 100644 ShadowsocksX-NG2/Domain/MacOSPluginInspection.swift create mode 100644 ShadowsocksX-NG2/Domain/PluginCatalog.swift create mode 100644 ShadowsocksX-NG2/Domain/PluginCatalogSnapshot.swift create mode 100644 ShadowsocksX-NG2/Domain/PluginMappingFileStore.swift create mode 100644 ShadowsocksX-NG2/Tests/PluginCatalogTests.swift create mode 100644 ShadowsocksX-NG2/Tests/PluginSecurityInspectionTests.swift create mode 100644 docs/design/user-plugin-catalog-spec.md diff --git a/GLOSSARY.md b/GLOSSARY.md index 8b33ea8d..4499591a 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -54,7 +54,13 @@ _避免用词_:可移除节点(Removable node)。 没有已知本地阻断问题、可用于生成代理配置的服务器配置。它不保证远程可达,也不表示插件参数已经验证有效。 **托管插件(Managed plugin)**: -由产品随应用发布的 SIP003 插件,用户可选择的插件程序限定在这一集合中。 +由产品随应用发布的 SIP003 插件,其发布版本与来源事实属于随应用提供的程序,不代表用户自备的同名程序。 + +**用户插件映射(User plugin mapping)**: +当前 macOS 用户维护的插件名称与本机可执行文件路径之间的对应关系。同名映射覆盖托管插件的默认选择,但不改写服务器配置中的插件引用。 + +**有效插件目录(Effective plugin catalog)**: +由当前应用提供的托管插件与用户插件映射共同确定的插件名称、实际来源与可用性事实集合。失效的用户映射仍保留其覆盖关系,不自动退回同名托管插件。 **旧版配置(Legacy configuration)**: 冻结的旧版客户端保存的服务器列表与偏好设置。 diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift index e19ba2d9..bf6025af 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift @@ -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 { diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift index 0e76f189..0141c08d 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift @@ -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 } diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift index 6424a98d..8ef0c49e 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift @@ -13,7 +13,7 @@ struct CatalogWorkflowDependencies { /// 凭据存储(story 11):只在 module 内出现,UI 不持有。 let credentials: CredentialStoring /// 受管插件提供缝(编辑面集内事实与可执行文件存在性)。 - let plugins: ManagedPluginProviding + let plugins: PluginExecutableResolving /// 订阅获取缝。 let subscriptionFetcher: SubscriptionFetching /// Legacy 导入服务(快照发现、完成标记与原子导入)。 diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift index 73a5c834..bb180406 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift @@ -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 门禁、订阅不存在);获取/解析失败仍以原错误上抛。 diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index 9f7c20a0..46a57bfa 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -196,7 +196,7 @@ extension AppPresentation { case .unsupportedEncryptionMethod: return "当前 sslocal 不支持该加密方法" case .invalidPassword: return "密码不能为空" case .emptyName: return "名称不能为空" - case .pluginNotManaged: return "本版本未提供所选插件" + case .pluginUnknown: return "本版本未提供所选插件" } } diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index ee798637..7b115328 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -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 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift index 27449ff5..abc22764 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift @@ -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 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift index 669e1fd8..46361fee 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift @@ -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 继续监听。返回结构化收敛 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index cd5b81a9..f0882709 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -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) diff --git a/ShadowsocksX-NG2/App/ServerDetailView.swift b/ShadowsocksX-NG2/App/ServerDetailView.swift index 825b372a..e3fe36e2 100644 --- a/ShadowsocksX-NG2/App/ServerDetailView.swift +++ b/ShadowsocksX-NG2/App/ServerDetailView.swift @@ -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) } } diff --git a/ShadowsocksX-NG2/App/ServerFormFields.swift b/ShadowsocksX-NG2/App/ServerFormFields.swift index a47ef2d7..7a3759b7 100644 --- a/ShadowsocksX-NG2/App/ServerFormFields.swift +++ b/ShadowsocksX-NG2/App/ServerFormFields.swift @@ -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 } } diff --git a/ShadowsocksX-NG2/App/ServerPluginSection.swift b/ShadowsocksX-NG2/App/ServerPluginSection.swift index 37f9a4ce..ce41ac75 100644 --- a/ShadowsocksX-NG2/App/ServerPluginSection.swift +++ b/ShadowsocksX-NG2/App/ServerPluginSection.swift @@ -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) diff --git a/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift b/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift index 3f2ef959..1f4a3b23 100644 --- a/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift +++ b/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift @@ -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 { 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 { guard let entry = catalog.entry(for: leafID), case .server(let fields) = entry.kind else { preconditionFailure("展开结果只含服务器叶子") diff --git a/ShadowsocksX-NG2/Domain/DiagnosticReport.swift b/ShadowsocksX-NG2/Domain/DiagnosticReport.swift index 0880fd36..de11b570 100644 --- a/ShadowsocksX-NG2/Domain/DiagnosticReport.swift +++ b/ShadowsocksX-NG2/Domain/DiagnosticReport.swift @@ -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("") diff --git a/ShadowsocksX-NG2/Domain/MacOSPluginInspection.swift b/ShadowsocksX-NG2/Domain/MacOSPluginInspection.swift new file mode 100644 index 00000000..b24f90b7 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/MacOSPluginInspection.swift @@ -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 + } +} diff --git a/ShadowsocksX-NG2/Domain/ManagedPluginProviding.swift b/ShadowsocksX-NG2/Domain/ManagedPluginProviding.swift index 8be4c69a..06d516b6 100644 --- a/ShadowsocksX-NG2/Domain/ManagedPluginProviding.swift +++ b/ShadowsocksX-NG2/Domain/ManagedPluginProviding.swift @@ -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 } } diff --git a/ShadowsocksX-NG2/Domain/PluginCatalog.swift b/ShadowsocksX-NG2/Domain/PluginCatalog.swift new file mode 100644 index 00000000..fa830f84 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/PluginCatalog.swift @@ -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] = [:] + let didCommit = PassthroughSubject() + @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 { + 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 + } +} diff --git a/ShadowsocksX-NG2/Domain/PluginCatalogSnapshot.swift b/ShadowsocksX-NG2/Domain/PluginCatalogSnapshot.swift new file mode 100644 index 00000000..0788cf38 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/PluginCatalogSnapshot.swift @@ -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 } +} diff --git a/ShadowsocksX-NG2/Domain/PluginMappingFileStore.swift b/ShadowsocksX-NG2/Domain/PluginMappingFileStore.swift new file mode 100644 index 00000000..5aecc29b --- /dev/null +++ b/ShadowsocksX-NG2/Domain/PluginMappingFileStore.swift @@ -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) + } +} diff --git a/ShadowsocksX-NG2/Domain/ServerValidation.swift b/ShadowsocksX-NG2/Domain/ServerValidation.swift index 4514fbf5..2ac6197c 100644 --- a/ShadowsocksX-NG2/Domain/ServerValidation.swift +++ b/ShadowsocksX-NG2/Domain/ServerValidation.swift @@ -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 } diff --git a/ShadowsocksX-NG2/README.md b/ShadowsocksX-NG2/README.md index f117dd61..d88ada02 100644 --- a/ShadowsocksX-NG2/README.md +++ b/ShadowsocksX-NG2/README.md @@ -98,3 +98,11 @@ Python 是唯一的上游转换实现;Swift 只加载、校验并消费快照 ## 依赖升级 升级任一外部二进制是显式供应链动作:人工复验新 release(下载归档、静态观测哈希/架构/签名,不执行),随后在一次 PR 中同时改 `Vendor//manifest.json` 的 release/asset/url/archiveSHA256 与对应测试锚点。不接受 `latest`,不在仓库内构建,不做应用内更新通道(升级仅随 app 发版)。 + +### 用户插件目录 + +`PluginCatalog` 合成当前托管清单与本机 `plugins.json` 用户映射,名称精确区分大小写。用户映射优先,失效 override 不退回托管程序;删除 override 恢复当前 app 默认值。文件读取错误保留阻断事实,不按空映射处理。用户路径不进入服务器资料、订阅或分享文件,也不进入诊断报告。 + +当前实现提供映射提交与显式修复 interface,并在组合根统一接入表单、激活与诊断;管理 UI 留待后续实现。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。 + +`MacOSPluginInspection` 只读取下载隔离、签名与系统评估事实,不启动用户插件、不修改系统策略。检查失败或不适用为未知;策略评估拒绝不等于实际执行禁令,也不阻止映射保存或激活。真实 macOS 拦截提示不由 hostless 替身测试证明。 diff --git a/ShadowsocksX-NG2/Tests/ActivationFixture.swift b/ShadowsocksX-NG2/Tests/ActivationFixture.swift index 4defc907..ccbfd406 100644 --- a/ShadowsocksX-NG2/Tests/ActivationFixture.swift +++ b/ShadowsocksX-NG2/Tests/ActivationFixture.swift @@ -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? { diff --git a/ShadowsocksX-NG2/Tests/CatalogFixtures.swift b/ShadowsocksX-NG2/Tests/CatalogFixtures.swift index aebe8c28..914aabb6 100644 --- a/ShadowsocksX-NG2/Tests/CatalogFixtures.swift +++ b/ShadowsocksX-NG2/Tests/CatalogFixtures.swift @@ -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, diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift index ce07c377..907a877f 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift @@ -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")), ] } } diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift index 8850c403..521afd9a 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift @@ -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") } } diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift index 2bd750d2..199bc384 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift @@ -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", "被删除的参数秘密已恢复") } diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift index 4839f0fa..ae53ae3a 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift @@ -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) diff --git a/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift b/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift new file mode 100644 index 00000000..d08f335d --- /dev/null +++ b/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift @@ -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.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.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 + private let signal: AsyncStream.Continuation + private var pending: CheckedContinuation? + + init() { + let stream = AsyncStream.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) + } +} diff --git a/ShadowsocksX-NG2/Tests/PluginSecurityInspectionTests.swift b/ShadowsocksX-NG2/Tests/PluginSecurityInspectionTests.swift new file mode 100644 index 00000000..e0cefb36 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/PluginSecurityInspectionTests.swift @@ -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) + } +} diff --git a/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift b/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift index bfc8cffc..4391ca5c 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift @@ -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, diff --git a/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift b/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift index 34ec3e56..c23c2ee2 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift @@ -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 diff --git a/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift b/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift index a3d82dcb..ea43011d 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift @@ -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, diff --git a/ShadowsocksX-NG2/Tests/SkeletonTests.swift b/ShadowsocksX-NG2/Tests/SkeletonTests.swift index 003cd94a..37f9dedd 100644 --- a/ShadowsocksX-NG2/Tests/SkeletonTests.swift +++ b/ShadowsocksX-NG2/Tests/SkeletonTests.swift @@ -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", ] diff --git a/docs/design/user-plugin-catalog-spec.md b/docs/design/user-plugin-catalog-spec.md new file mode 100644 index 00000000..3c5760cf --- /dev/null +++ b/docs/design/user-plugin-catalog-spec.md @@ -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 或推送。实现按用户授权在当前分支作本地提交。