mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 05:03:49 +00:00
feat(plugins): add user plugin catalog and runtime wiring
This commit is contained in:
+7
-1
@@ -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 门禁、订阅不存在);获取/解析失败仍以原错误上抛。
|
||||
|
||||
@@ -196,7 +196,7 @@ extension AppPresentation {
|
||||
case .unsupportedEncryptionMethod: return "当前 sslocal 不支持该加密方法"
|
||||
case .invalidPassword: return "密码不能为空"
|
||||
case .emptyName: return "名称不能为空"
|
||||
case .pluginNotManaged: return "本版本未提供所选插件"
|
||||
case .pluginUnknown: return "本版本未提供所选插件"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 或推送。实现按用户授权在当前分支作本地提交。
|
||||
Reference in New Issue
Block a user