From 09686a80db4823bd20b349f5086b68803510e1df Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Fri, 2 Oct 2026 21:38:03 +0800 Subject: [PATCH] feat(rules): persist cross-source enablement and recover deployments (#78) --- CONTEXT.md | 2 +- ShadowsocksX-NG2/App/MainApp.swift | 5 +- .../App/ProxyRuntimeController+Commands.swift | 7 +- .../ProxyRuntimeController+CustomRules.swift | 130 ++++++++++---- .../App/ProxyRuntimeController+Mode.swift | 17 +- .../App/ProxyRuntimeController.swift | 1 + ShadowsocksX-NG2/App/Rules.xcstrings | 143 +++++++++++++++ ShadowsocksX-NG2/App/RulesView.swift | 63 ++++++- ShadowsocksX-NG2/App/RulesWorkflow.swift | 64 ++++++- .../Domain/BuiltinRuleCatalog.swift | 4 +- ShadowsocksX-NG2/Domain/CustomRule.swift | 46 ++++- ShadowsocksX-NG2/Domain/CustomRuleStore.swift | 25 ++- .../Domain/OfflineRuleMatcher.swift | 1 + ShadowsocksX-NG2/Domain/RuleAnalysis.swift | 22 +++ ShadowsocksX-NG2/Domain/RulesBrowsing.swift | 7 +- ShadowsocksX-NG2/Domain/RulesCollection.swift | 34 +++- .../Tests/BuiltinRuleCatalogTests.swift | 4 +- .../Tests/CustomRuleControllerTests.swift | 6 +- .../Tests/CustomRuleStoreTests.swift | 23 ++- .../Tests/RuleEnablementControllerTests.swift | 166 ++++++++++++++++++ .../Tests/RuleEnablementWorkflowTests.swift | 73 ++++++++ .../Tests/RulesWorkflowTests.swift | 56 ++++++ 22 files changed, 821 insertions(+), 78 deletions(-) create mode 100644 ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift create mode 100644 ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index f111d2bf..0f6b9635 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -104,6 +104,6 @@ - Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once. - The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available. - The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model. -- The Proxy Rules destination provides read-only browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or refreshing clears prior results, and generation/version checks reject late tasks. The page has no add, edit, disable, or delete actions yet; it does not change runtime source selection. +- The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or refreshing clears prior results, and generation/version checks reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton, restoring preserved expressible absorbed candidates and GFWList exceptions when their blockers are disabled. Commits show progress and reject repeats, preserve the runtime switch intent, and deploy only if ACL identity changes. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The page has no add, edit, or delete actions yet. - The app presents in the adaptive app form: menu-bar-only after the last workspace or independent rule-report window closes, and with a Dock icon and app-switcher presence while either is open; hiding the app is not closing it and does not change the form; the GUI process keeps running with the status menu available regardless of the workspace window. - At launch the GUI presents the workspace window unless silent launch is on; after the workspace window is closed, the status menu is the only way to reopen it. Silent launch changes only window presentation: proxy runtime restoration happens at every launch regardless of it. diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index ec847de8..099f3093 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -157,7 +157,10 @@ private struct AppComposition { let workspaceRoute = WorkspaceRoute() let silentLaunch = SilentLaunchController(store: dependencies.silentLaunchStore) let expansion = CatalogExpansionState() - let rulesWorkflow = RulesWorkflow() + let rulesStore = controller.customRuleStore + let rulesWorkflow = RulesWorkflow( + loadDocument: { try rulesStore.loadDocument() }, + commitDocument: { await controller.updateRuleDocument($0) }) let workspaceContent = MainWindowView( rulesWorkflow: rulesWorkflow, route: workspaceRoute, diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift index 56cb99ec..3f16b047 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift @@ -270,6 +270,7 @@ extension ProxyRuntimeController { func execute(_ intent: RuntimeIntent, document: SslocalRuntimeDocument?) async -> Bool { cancelFirewallObservation() flowGeneration += 1 + let generation = flowGeneration let actions = ProxyRuntimePlan.actions( intent: intent, agentStatus: agent.status, @@ -279,9 +280,11 @@ extension ProxyRuntimeController { RuntimeLog.emit(.contractUnchanged) } for action in actions { - guard await perform(action, document: document) else { return false } + guard generation == flowGeneration, + await perform(action, document: document) + else { return false } } - return true + return generation == flowGeneration } /// 执行单个动作;返回 false 表示应终止后续动作(状态已呈现)。 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift index df3681f7..918cd4c6 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift @@ -12,6 +12,10 @@ enum CustomRuleUpdateOutcome: Equatable, Sendable { case persistenceFailed /// 保存后部署失败并已回滚到旧规则与旧运行时。 case rolledBack + case recoveryFailed(detail: String) + case busy + case versionConflict + case invalidDocument(detail: String) } // MARK: - 自定义规则命令面 @@ -21,29 +25,40 @@ extension ProxyRuntimeController { /// 校验拒绝时整批不落地;部署失败时回滚旧规则、旧 ACL 与旧系统代理状态。 /// 全局和直连模式不加载自定义规则,但持久化仍然进行(切换到规则模式后生效)。 func updateCustomRules(_ rules: [CustomRule]) async -> CustomRuleUpdateOutcome { - let validation = validateCustomRulesForPersistence(rules) - guard validation.rejected.isEmpty else { - return .rejected(validation.rejected) - } - - let previousRules = (try? customRuleStore.load()) ?? [] - let snapshot = ModeTransitionSnapshotForRules( - document: lastDocument, state: state) - do { - try customRuleStore.save(validation.acceptedRules) + let previous = try customRuleStore.loadDocument() + return await updateRuleDocument( + CustomRuleDocument(rules: rules, disabledIdentities: previous.disabledIdentities)) + } catch { return .persistenceFailed } + } + + func updateRuleDocument(_ document: CustomRuleDocument) async -> CustomRuleUpdateOutcome { + guard !isUpdatingRules else { return .busy } + isUpdatingRules = true + defer { isUpdatingRules = false } + let validation = validateCustomRulesForPersistence(document.rules) + guard validation.rejected.isEmpty else { return .rejected(validation.rejected) } + guard + document.disabledIdentities.isDisjoint( + with: + Set(RuleCoverage.fixedLocalMatches.map { RuleIdentity(action: .direct, match: $0) })) + else { return .invalidDocument(detail: "Fixed local policy cannot be disabled") } + let previous: CustomRuleDocument + let snapshot = ModeTransitionSnapshotForRules( + document: lastDocument ?? runtimeFileStore.loadDocument(), state: state) + do { + previous = try customRuleStore.loadDocument() + try customRuleStore.saveDocument(document) } catch { RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) - state = .serviceFailed(.persistence) return .persistenceFailed } - - return await deployCustomRuleChange(previousRules: previousRules, snapshot: snapshot) + return await deployCustomRuleChange(previousRules: previous, snapshot: snapshot) } /// 规则内容变化后重编译 ACL;非规则模式的 ACL 不含自定义规则,无变化即不重启。 private func deployCustomRuleChange( - previousRules: [CustomRule], + previousRules: CustomRuleDocument, snapshot: ModeTransitionSnapshotForRules ) async -> CustomRuleUpdateOutcome { guard settings.agentEnabled, state != .off, @@ -57,8 +72,12 @@ extension ProxyRuntimeController { nextDocument = try runtimeDocument(currentDocument, for: proxyMode) } catch { RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) - await restoreCustomRules(previousRules, snapshot: snapshot) - return .rolledBack + do { + try customRuleStore.saveDocument(previousRules) + return .rolledBack + } catch { + return .recoveryFailed(detail: String(describing: error)) + } } guard nextDocument.aclRuntime != currentDocument.aclRuntime else { return .saved @@ -66,28 +85,44 @@ extension ProxyRuntimeController { modeChangeGeneration += 1 let generation = modeChangeGeneration + let runtimeGeneration = flowGeneration + 1 lastDocument = nextDocument state = .starting guard await execute(.run(nextDocument), document: nextDocument) else { - guard generation == modeChangeGeneration else { return .rolledBack } - await restoreCustomRules(previousRules, snapshot: snapshot) - return .rolledBack + guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration, + settings.agentEnabled + else { + return .recoveryFailed(detail: "Runtime changed during rule deployment") + } + return await restoreCustomRules(previousRules, snapshot: snapshot) } - guard generation == modeChangeGeneration else { return .rolledBack } + guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration, + settings.agentEnabled + else { + return .recoveryFailed(detail: "Runtime changed during rule deployment") + } let healthy = await presentLaunchHealth( nextDocument, requiresReceipt: true, convergeProxyOnSuccess: false, preserveProxyOnFailure: true) - guard generation == modeChangeGeneration else { return .rolledBack } + guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration, + settings.agentEnabled + else { + return .recoveryFailed(detail: "Runtime changed during rule deployment") + } guard healthy else { - await restoreCustomRules(previousRules, snapshot: snapshot) - return .rolledBack + return await restoreCustomRules(previousRules, snapshot: snapshot) } lastDocument = nextDocument await convergeSystemProxy() + guard generation == modeChangeGeneration, runtimeGeneration == flowGeneration, + settings.agentEnabled + else { + return .recoveryFailed(detail: "Runtime changed during rule deployment") + } return .saved } @@ -107,27 +142,53 @@ extension ProxyRuntimeController { } private func restoreCustomRules( - _ previousRules: [CustomRule], + _ previousRules: CustomRuleDocument, snapshot: ModeTransitionSnapshotForRules - ) async { + ) async -> CustomRuleUpdateOutcome { + var failures: [String] = [] do { - try customRuleStore.save(previousRules) + try customRuleStore.saveDocument(previousRules) } catch { + failures.append(String(describing: error)) RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) } - guard let previousDocument = snapshot.document else { return } + guard let previousDocument = snapshot.document else { + return failures.isEmpty + ? .rolledBack : .recoveryFailed(detail: failures.joined(separator: "; ")) + } + guard settings.agentEnabled else { + return .recoveryFailed(detail: "Agent disabled during rule recovery") + } + let modeGeneration = modeChangeGeneration + let runtimeGeneration = flowGeneration + 1 lastDocument = previousDocument state = .starting - _ = await execute(.run(previousDocument), document: previousDocument) - let restored = await presentLaunchHealth( - previousDocument, - requiresReceipt: true, - convergeProxyOnSuccess: false) - if !restored { + let executed = await execute(.run(previousDocument), document: previousDocument) + guard modeGeneration == modeChangeGeneration, runtimeGeneration == flowGeneration, + settings.agentEnabled + else { + return .recoveryFailed(detail: "Runtime changed during rule recovery") + } + guard executed else { + failures.append(String(describing: state)) + await holdSystemProxyIntent() + return .recoveryFailed(detail: failures.joined(separator: "; ")) + } + let healthy = await presentLaunchHealth( + previousDocument, requiresReceipt: true, convergeProxyOnSuccess: false) + guard modeGeneration == modeChangeGeneration, runtimeGeneration == flowGeneration, + settings.agentEnabled + else { + return .recoveryFailed(detail: "Runtime changed during rule recovery") + } + if !healthy { + failures.append(String(describing: state)) await holdSystemProxyIntent() } else { state = snapshot.state } + return failures.isEmpty + ? .rolledBack : .recoveryFailed(detail: failures.joined(separator: "; ")) } private struct ModeTransitionSnapshotForRules { @@ -137,7 +198,6 @@ extension ProxyRuntimeController { /// 自定义规则安全摘要(issue #66 AC5):数量 + 内容版本,不含原始域名。 func readCustomRuleSummary() -> CustomRuleSummary? { - guard let rules = try? customRuleStore.load() else { return nil } - return CustomRuleSummary.summarizing(rules) + try? customRuleStore.summary() } } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift index 32145c27..6c1e1a0b 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift @@ -112,21 +112,28 @@ extension ProxyRuntimeController { /// 规则模式的完整有效候选、硬拒绝项与当前 ACL 骨架下的覆盖说明。 func ruleModeValidation() throws -> CustomRuleValidationResult { - let custom = try customRuleStore.load() + let document = try customRuleStore.loadDocument() + let custom = document.rules.filter { !document.disabledIdentities.contains($0.identity) } switch settings.ruleDefaultAction { case .proxyWhenUnmatched: - let builtIn = try chinaDirectRules() + let builtIn = try chinaDirectRules().filter { + !document.disabledIdentities.contains($0.identity) + } let validated = CustomRuleValidator.validate( custom: custom, builtIn: builtIn, defaultAction: .proxyWhenUnmatched) return CustomRuleValidationResult( - accepted: builtIn + validated.accepted, rejected: validated.rejected, + accepted: RuleAnalysis.runtimeCandidates( + builtIn + validated.accepted, defaultAction: settings.ruleDefaultAction), + rejected: validated.rejected, relationships: validated.relationships) case .directWhenUnmatched: - let builtIn = try gfwlistRules() + let builtIn = try gfwlistRules().filter { !document.disabledIdentities.contains($0.identity) } let validated = CustomRuleValidator.validate( custom: custom, builtIn: builtIn, defaultAction: .directWhenUnmatched) return CustomRuleValidationResult( - accepted: builtIn + validated.accepted, rejected: validated.rejected, + accepted: RuleAnalysis.runtimeCandidates( + builtIn + validated.accepted, defaultAction: settings.ruleDefaultAction), + rejected: validated.rejected, relationships: validated.relationships) } } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index 6deb4661..63f3a8c3 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -65,6 +65,7 @@ final class ProxyRuntimeController: ObservableObject { let plugins: ManagedPluginProviding let settingsStore: ProxySettingsStoring /// 自定义规则持久化(issue #66):规则模式 ACL 合并的用户入口。 + var isUpdatingRules = false let customRuleStore: CustomRuleStore /// 监听设置不可读时的点名原因(D8「任何路径不静默改端口」);非 nil 时 /// 设置只是占位出厂默认,禁止部署(见 `deploy`)。 diff --git a/ShadowsocksX-NG2/App/Rules.xcstrings b/ShadowsocksX-NG2/App/Rules.xcstrings index e9061e65..0cd446dd 100644 --- a/ShadowsocksX-NG2/App/Rules.xcstrings +++ b/ShadowsocksX-NG2/App/Rules.xcstrings @@ -584,6 +584,149 @@ } } } + }, + "启用": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable" + } + } + } + }, + "禁用": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable" + } + } + } + }, + "全部状态": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All states" + } + } + } + }, + "已启用": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enabled" + } + } + } + }, + "已禁用": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disabled" + } + } + } + }, + "无当前来源": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No current source" + } + } + } + }, + "正在保存并应用…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saving and applying…" + } + } + } + }, + "恢复失败": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recovery failed" + } + } + } + }, + "已保存": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved" + } + } + } + }, + "保存失败": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save failed" + } + } + } + }, + "部署失败,已恢复": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deployment failed; restored" + } + } + } + }, + "版本已变化,请刷新后重试": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rules changed; refresh and retry" + } + } + } + }, + "规则文档无效": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid rule document" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/RulesView.swift b/ShadowsocksX-NG2/App/RulesView.swift index 034503f9..fc405576 100644 --- a/ShadowsocksX-NG2/App/RulesView.swift +++ b/ShadowsocksX-NG2/App/RulesView.swift @@ -9,6 +9,12 @@ struct RulesView: View { VStack(spacing: 0) { RulesAddressTestView(workflow: workflow) Divider() + if workflow.snapshot.isCommitting { + ProgressView(RulesCopy.text("正在保存并应用…")).padding() + } + if let outcome = workflow.snapshot.commitOutcome { + Text(verbatim: outcome.rulesMessage).textSelection(.enabled).padding(.horizontal) + } if workflow.snapshot.isLoading { ProgressView(RulesCopy.text("加载规则…")).padding() } @@ -94,6 +100,16 @@ struct RulesView: View { Text(RulesCopy.text("直连")).tag(Optional(RuleAction.direct)) Text(RulesCopy.text("代理")).tag(Optional(RuleAction.proxy)) }.labelsHidden().fixedSize() + Picker( + RulesCopy.text("状态"), + selection: Binding( + get: { workflow.snapshot.query.enabled }, + set: { value in updateQuery { $0.enabled = value } }) + ) { + Text(RulesCopy.text("全部状态")).tag(Optional.none) + Text(RulesCopy.text("已启用")).tag(Optional(true)) + Text(RulesCopy.text("已禁用")).tag(Optional(false)) + }.labelsHidden().fixedSize() Picker( RulesCopy.text("匹配内容"), selection: Binding( @@ -113,6 +129,19 @@ struct RulesView: View { get: { workflow.snapshot.selection }, set: { ids in DispatchQueue.main.async { workflow.select(ids) } }) ) { + TableColumn(RulesCopy.text("启用")) { row in + Toggle( + RulesCopy.text("启用"), + isOn: Binding( + get: { row.isEnabled }, + set: { enabled in + guard let identity = row.identity else { return } + Task { await workflow.setEnabled(enabled, identities: [identity]) } + }) + ) + .labelsHidden() + .disabled(row.isFixed || !workflow.snapshot.isComplete || workflow.snapshot.isCommitting) + }.width(45) TableColumn(RulesCopy.text("匹配内容")) { row in Text(verbatim: row.displayContent) } .width(min: 120, ideal: 170) TableColumn(RulesCopy.text("类型")) { row in Text(verbatim: row.matchType) } @@ -124,6 +153,21 @@ struct RulesView: View { TableColumn(RulesCopy.text("状态")) { row in Text(row.statusLabel) } .width(min: 80, ideal: 110) }.frame(minWidth: 0, maxWidth: .infinity) + .contextMenu { + ForEach([true, false], id: \.self) { enabled in + Button { + let identities = workflow.actionableSelection + Task { await workflow.setEnabled(enabled, identities: identities) } + } label: { + Text( + RulesCopy.text(enabled ? "启用" : "禁用") + " (" + + String(workflow.actionableSelection.count) + ")") + } + .disabled( + workflow.actionableSelection.isEmpty || !workflow.snapshot.isComplete + || workflow.snapshot.isCommitting) + } + } } private var sourceBinding: Binding { @@ -228,12 +272,14 @@ extension RulesSource { extension RulesRow { var displayContent: String { identity == nil ? RulesCopy.text("简单主机名") : content } var sourceLabels: String { - RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ") + guard hasCurrentSource else { return RulesCopy.text("无当前来源") } + return RulesSource.allCases.filter { sources.contains($0) }.map(\.label).joined(separator: ", ") } var actionLabel: String { RulesCopy.text(action == .direct ? "直连" : "代理") } var matchType: String { identity?.match.browsingType ?? RulesCopy.text("简单主机名") } var statusLabel: String { if isFixed { return RulesCopy.text("固定本地策略") } + if !isEnabled { return RulesCopy.text("已禁用") } var labels = relationships.map(\.browsingLabel) if let fixedCoverage { let label = @@ -268,3 +314,18 @@ extension RuleMatch { } } } + +extension CustomRuleUpdateOutcome { + var rulesMessage: String { + switch self { + case .saved: RulesCopy.text("已保存") + case .persistenceFailed: RulesCopy.text("保存失败") + case .rolledBack: RulesCopy.text("部署失败,已恢复") + case .recoveryFailed(let detail): RulesCopy.text("恢复失败") + ": " + detail + case .busy: RulesCopy.text("正在保存并应用…") + case .versionConflict: RulesCopy.text("版本已变化,请刷新后重试") + case .invalidDocument(let detail): RulesCopy.text("规则文档无效") + ": " + detail + case .rejected(let rejected): rejected.map(\.explanation).joined(separator: "; ") + } + } +} diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift index dc83e06c..b850f22c 100644 --- a/ShadowsocksX-NG2/App/RulesWorkflow.swift +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -9,12 +9,16 @@ final class RulesWorkflow: ObservableObject { @Published private(set) var reportSource: RulesSourceSnapshot? private let loadCustom: @Sendable () throws -> [CustomRule] private let loadBuiltin: @Sendable (RulesSource) throws -> RuleSnapshot + private let loadDocument: (@Sendable () throws -> CustomRuleDocument)? + private let commitDocument: (@MainActor (CustomRuleDocument) async -> CustomRuleUpdateOutcome)? private var collection: RulesCollection? private var refreshGeneration = 0 private var testGeneration = 0 init( - loadCustom: @escaping @Sendable () throws -> [CustomRule] = { try CustomRuleStore().load() }, + loadCustom: (@Sendable () throws -> [CustomRule])? = nil, + loadDocument: (@Sendable () throws -> CustomRuleDocument)? = nil, + commitDocument: (@MainActor (CustomRuleDocument) async -> CustomRuleUpdateOutcome)? = nil, loadBuiltin: @escaping @Sendable (RulesSource) throws -> RuleSnapshot = { source in switch source { case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN() @@ -24,19 +28,33 @@ final class RulesWorkflow: ObservableObject { } } ) { - self.loadCustom = loadCustom + if let loadDocument { + self.loadDocument = loadDocument + } else if loadCustom == nil { + self.loadDocument = { try CustomRuleStore().loadDocument() } + } else { + self.loadDocument = nil + } + self.commitDocument = commitDocument + self.loadCustom = loadCustom ?? { try CustomRuleStore().load() } self.loadBuiltin = loadBuiltin } func refresh() async { + guard !snapshot.isCommitting else { return } + await refreshCollection() + } + + private func refreshCollection() async { refreshGeneration += 1 let generation = refreshGeneration invalidateAddressTest() snapshot.isLoading = true let custom = loadCustom let builtin = loadBuiltin + let document = loadDocument let result = await Task.detached(priority: .userInitiated) { - RulesCollection.load(custom: custom, builtin: builtin) + RulesCollection.load(custom: custom, builtin: builtin, document: document) }.value guard generation == refreshGeneration else { return } collection = result @@ -48,6 +66,44 @@ final class RulesWorkflow: ObservableObject { snapshot = applyingQuery(to: next) } + var actionableSelection: Set { + Set( + snapshot.rows.filter { snapshot.selection.contains($0.id) && !$0.isFixed } + .compactMap(\.identity)) + } + + func setEnabled(_ enabled: Bool, identities: Set) async { + guard snapshot.isComplete, !snapshot.isCommitting, + let commitDocument, let loadDocument + else { return } + let allowed = Set(snapshot.rows.filter { !$0.isFixed }.compactMap(\.identity)) + let targets = identities.intersection(allowed) + guard !targets.isEmpty else { return } + snapshot.isCommitting = true + snapshot.commitOutcome = nil + invalidateAddressTest() + defer { snapshot.isCommitting = false } + do { + let old = try loadDocument() + guard let prepared = collection?.userDocument, + old.rules == prepared.rules && old.disabledIdentities == prepared.disabledIdentities + else { + snapshot.commitOutcome = .versionConflict + await refreshCollection() + return + } + var disabled = old.disabledIdentities + if enabled { disabled.subtract(targets) } else { disabled.formUnion(targets) } + guard disabled != old.disabledIdentities else { return } + let outcome = await commitDocument( + CustomRuleDocument(rules: old.rules, disabledIdentities: disabled)) + snapshot.commitOutcome = outcome + await refreshCollection() + } catch { + snapshot.commitOutcome = .persistenceFailed + } + } + func setTestTarget(_ target: String) { guard target != snapshot.addressTest.target else { return } invalidateAddressTest() @@ -124,6 +180,8 @@ final class RulesWorkflow: ObservableObject { var rows = collection.rows.filter { row in (query.action == nil || row.action == query.action) && (query.source.map { row.sources.contains($0) } ?? true) + && (query.enabled.map { row.isEnabled == $0 } ?? true) + && (row.hasCurrentSource || (query.source == nil && query.enabled == false)) && (search.isEmpty || row.content.lowercased().contains(search)) } if query.sort == .descending { rows.reverse() } diff --git a/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift b/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift index 35fd4fea..c4fac693 100644 --- a/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift +++ b/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift @@ -32,7 +32,7 @@ struct BuiltinRuleCatalog { /// 供 ACL 编译的中国直连候选(`.cn` 后缀 + geolocation-cn + china-ipv4)。 static func chinaDirectRules(from snapshot: RuleSnapshot) -> [ProxyRule] { - snapshot.rules.filter { $0.action == .direct } + (snapshot.rules + snapshot.absorbed).filter { $0.action == .direct } } /// 合并多份快照的直连候选;保持输入顺序(域名在前,CIDR 在后)。 @@ -43,6 +43,6 @@ struct BuiltinRuleCatalog { /// 供 ACL 编译的 GFWList 候选(issue #65):可准确表达且未被遮蔽的规则 /// (代理候选 + 未遮蔽例外),两种动作都参与编译。 static func gfwlistRules(from snapshot: RuleSnapshot) -> [ProxyRule] { - snapshot.rules + snapshot.rules + snapshot.absorbed } } diff --git a/ShadowsocksX-NG2/Domain/CustomRule.swift b/ShadowsocksX-NG2/Domain/CustomRule.swift index 1d3e6040..f5aaf0a9 100644 --- a/ShadowsocksX-NG2/Domain/CustomRule.swift +++ b/ShadowsocksX-NG2/Domain/CustomRule.swift @@ -43,14 +43,43 @@ struct CustomRule: Codable, Equatable, Hashable, Sendable, Identifiable { /// 自定义规则持久化文档(issue #66):schema 版本 + 规则数组。 struct CustomRuleDocument: Codable, Equatable, Sendable { - static let currentSchemaVersion = 1 - + static let currentSchemaVersion = 2 let schemaVersion: Int let rules: [CustomRule] + let disabledIdentities: Set - init(rules: [CustomRule]) { - self.schemaVersion = Self.currentSchemaVersion + init(rules: [CustomRule], disabledIdentities: Set = []) { + schemaVersion = Self.currentSchemaVersion self.rules = rules + self.disabledIdentities = disabledIdentities + } + + private enum CodingKeys: String, CodingKey { case schemaVersion, rules, disabledIdentities } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decode(Int.self, forKey: .schemaVersion) + guard [1, Self.currentSchemaVersion].contains(schemaVersion) else { + throw CustomRuleStoreError.schemaVersionMismatch( + found: schemaVersion, expected: Self.currentSchemaVersion) + } + rules = try values.decode([CustomRule].self, forKey: .rules) + let disabled = + schemaVersion == 1 ? [] : try values.decode([RuleIdentity].self, forKey: .disabledIdentities) + disabledIdentities = Set(disabled) + guard disabledIdentities.count == disabled.count, + Set(rules.map(\.id)).count == rules.count, + Set(rules.map(\.identity)).count == rules.count, + rules.allSatisfy({ $0.source.kind == .custom }) + else { throw CustomRuleStoreError.corrupt(detail: "Duplicate or invalid user rule records") } + } + + func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(Self.currentSchemaVersion, forKey: .schemaVersion) + try values.encode(rules, forKey: .rules) + try values.encode( + disabledIdentities.sorted { $0.contentToken < $1.contentToken }, forKey: .disabledIdentities) } } @@ -71,6 +100,15 @@ struct CustomRuleSummary: Equatable, Sendable { /// 内容版本:规则内容(动作+匹配)的稳定短摘要。 let contentVersion: String + static func summarizing(_ document: CustomRuleDocument) -> CustomRuleSummary { + let tokens = + document.rules.map(\.contentToken).sorted() + + document.disabledIdentities.map { "disabled:" + $0.contentToken }.sorted() + return CustomRuleSummary( + count: document.rules.count, + contentVersion: String(ProxyACLDocument.digest(tokens.joined(separator: "\n")).prefix(12))) + } + static func summarizing(_ rules: [CustomRule]) -> CustomRuleSummary { CustomRuleSummary(count: rules.count, contentVersion: contentVersion(of: rules)) } diff --git a/ShadowsocksX-NG2/Domain/CustomRuleStore.swift b/ShadowsocksX-NG2/Domain/CustomRuleStore.swift index e9c1053c..c5fd53ca 100644 --- a/ShadowsocksX-NG2/Domain/CustomRuleStore.swift +++ b/ShadowsocksX-NG2/Domain/CustomRuleStore.swift @@ -16,30 +16,37 @@ struct CustomRuleStore { } /// 读取规则集合;文件不存在视为无自定义规则。 - func load() throws -> [CustomRule] { - guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + func load() throws -> [CustomRule] { try loadDocument().rules } + + func loadDocument() throws -> CustomRuleDocument { let data: Data do { data = try Data(contentsOf: fileURL) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + return CustomRuleDocument(rules: []) } catch { throw CustomRuleStoreError.ioFailure(detail: String(describing: error)) } let document: CustomRuleDocument do { document = try Self.decode(data) + } catch let error as CustomRuleStoreError { + throw error } catch { throw CustomRuleStoreError.corrupt(detail: String(describing: error)) } - guard document.schemaVersion == CustomRuleDocument.currentSchemaVersion else { - throw CustomRuleStoreError.schemaVersionMismatch( - found: document.schemaVersion, expected: CustomRuleDocument.currentSchemaVersion) - } - return document.rules + return document } /// 原子写入完整规则集合。 func save(_ rules: [CustomRule]) throws { - let data = try Self.encode(CustomRuleDocument(rules: rules)) + let old = try loadDocument() + try saveDocument(CustomRuleDocument(rules: rules, disabledIdentities: old.disabledIdentities)) + } + + func saveDocument(_ document: CustomRuleDocument) throws { + let data = try Self.encode(document) + _ = try Self.decode(data) do { try AtomicFileWriter.write(data, to: fileURL) } catch { @@ -49,7 +56,7 @@ struct CustomRuleStore { /// 安全摘要:数量 + 内容版本(issue #66 AC5)。 func summary() throws -> CustomRuleSummary { - CustomRuleSummary.summarizing(try load()) + CustomRuleSummary.summarizing(try loadDocument()) } static func decode(_ data: Data) throws -> CustomRuleDocument { diff --git a/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift b/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift index b14becd2..fcfbdd02 100644 --- a/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift +++ b/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift @@ -22,6 +22,7 @@ enum OfflineRuleMatcher { guard collection.issues.isEmpty else { throw Failure.incompleteCollection } let target = try OfflineRuleTarget(address) let matches = collection.rows.filter { row in + guard row.isEnabled else { return false } guard let identity = row.identity else { return target.isSimpleHostname } return RuleCoverage.domainCovers(identity.match, target.match) || RuleCoverage.ipCovers(identity.match, target.match) diff --git a/ShadowsocksX-NG2/Domain/RuleAnalysis.swift b/ShadowsocksX-NG2/Domain/RuleAnalysis.swift index 021a2e14..0b41912b 100644 --- a/ShadowsocksX-NG2/Domain/RuleAnalysis.swift +++ b/ShadowsocksX-NG2/Domain/RuleAnalysis.swift @@ -88,3 +88,25 @@ extension RuleCoverage { matches: matches, includesSimpleHostname: simple) } } + +extension RuleAnalysis { + /// Select the actual ACL skeleton before calculating coverage. + static func runtimeCandidates(_ rules: [ProxyRule], defaultAction: RuleDefaultAction) + -> [ProxyRule] + { + let expressed = rules.filter { defaultAction != .proxyWhenUnmatched || $0.action == .direct } + let index = RulesOverlapIndex(rules: expressed) + return expressed.filter { rule in + let analysis = RuleAnalysis(rules: index.overlapping(rule.identity.match), subjects: [rule]) + return !analysis.relationships.contains { + $0.extent == .full + && ($0.kind == .shadowing + || $0.covering.contains { other in + RuleCoverage.fullyCovered(rule.identity.match, by: [other.match]) + && (!RuleCoverage.fullyCovered(other.match, by: [rule.identity.match]) + || other.contentToken < rule.identity.contentToken) + }) + } + } + } +} diff --git a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift index 686c7e49..d5025bbc 100644 --- a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift +++ b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift @@ -7,6 +7,7 @@ struct RulesQuery: Equatable, Sendable { var action: RuleAction? var source: RulesSource? var sort: Sort = .ascending + var enabled: Bool? } enum RulesSource: String, CaseIterable, Identifiable, Sendable { @@ -33,6 +34,8 @@ struct RulesRow: Equatable, Identifiable, Sendable { let customIDs: Set let relationships: [RuleRelationship] let fixedCoverage: FixedRuleCoverage? + var isEnabled = true + var hasCurrentSource: Bool { !sources.isEmpty } var isFixed: Bool { sources.contains(.fixed) } var action: RuleAction { identity?.action ?? .direct } } @@ -44,11 +47,13 @@ struct RulesPageSnapshot: Equatable, Sendable { } var version = "" var isLoading = false + var isCommitting = false + var commitOutcome: CustomRuleUpdateOutcome? var issues: [Issue] = [] var sources: [RulesSourceSnapshot] = [] var rows: [RulesRow] = [] var selection: Set = [] var query = RulesQuery() var addressTest = RulesAddressTest() - var isComplete: Bool { !isLoading && issues.isEmpty && !version.isEmpty } + var isComplete: Bool { !isLoading && !isCommitting && issues.isEmpty && !version.isEmpty } } diff --git a/ShadowsocksX-NG2/Domain/RulesCollection.swift b/ShadowsocksX-NG2/Domain/RulesCollection.swift index 67dd6399..22b41458 100644 --- a/ShadowsocksX-NG2/Domain/RulesCollection.swift +++ b/ShadowsocksX-NG2/Domain/RulesCollection.swift @@ -6,13 +6,15 @@ struct RulesCollection: Sendable { let rows: [RulesRow] let sources: [RulesSourceSnapshot] let issues: [RulesPageSnapshot.Issue] + let userDocument: CustomRuleDocument? static func load( custom loadCustom: () throws -> [CustomRule], - builtin loadBuiltin: (RulesSource) throws -> RuleSnapshot + builtin loadBuiltin: (RulesSource) throws -> RuleSnapshot, + document loadDocument: (() throws -> CustomRuleDocument)? = nil ) -> RulesCollection { var input = RulesCollectionInput() - input.readCustom(loadCustom) + input.readCustom { try loadDocument?() ?? CustomRuleDocument(rules: loadCustom()) } for source in [RulesSource.geolocationCN, .chinaIPv4, .gfwlist] { input.readBuiltin(source, using: loadBuiltin) } @@ -27,14 +29,19 @@ private struct RulesCandidate { } private struct RulesCollectionInput { + var userDocument: CustomRuleDocument? + var disabled: Set = [] var entries: [RulesCandidate] = [] var sources: [RulesSourceSnapshot] = [] var issues: [RulesPageSnapshot.Issue] = [] var metadataTokens: [String] = [] - mutating func readCustom(_ loadCustom: () throws -> [CustomRule]) { + mutating func readCustom(_ loadCustom: () throws -> CustomRuleDocument) { do { - let custom = try loadCustom() + let document = try loadCustom() + let custom = document.rules + disabled = document.disabledIdentities + userDocument = document let validation = CustomRuleValidator.validate( custom: custom, defaultAction: .directWhenUnmatched) guard validation.rejected.isEmpty else { @@ -97,12 +104,21 @@ private struct RulesCollectionInput { // Completely fixed-protected proxy candidates remain browsable but cannot // cover or shadow another candidate in the effective collection. let effective = rules.filter { - $0.action != .proxy || RuleCoverage.fixedLocalCoverage(of: $0.identity.match)?.extent != .full + !disabled.contains($0.identity) + && ($0.action != .proxy + || RuleCoverage.fixedLocalCoverage(of: $0.identity.match)?.extent != .full) } let index = RulesOverlapIndex(rules: effective) var rows = grouped.map { identity, entries in row(identity: identity, entries: entries, index: index) } + for identity in disabled where grouped[identity] == nil { + rows.append( + RulesRow( + id: .rule(identity), identity: identity, + content: identity.match.browsingContent, sources: [], customIDs: [], + relationships: [], fixedCoverage: nil, isEnabled: false)) + } rows.append( RulesRow( id: .noDotHostname, identity: nil, content: "^[^.]+$", sources: [.fixed], customIDs: [], @@ -115,11 +131,12 @@ private struct RulesCollectionInput { rows.map { row in (row.identity?.contentToken ?? "fixed:no-dot") + "|" + row.sources.map(\.rawValue).sorted().joined(separator: ",") - + "|" + row.customIDs.map(\.uuidString).sorted().joined(separator: ",") + + "|" + String(row.isEnabled) + "|" + + row.customIDs.map(\.uuidString).sorted().joined(separator: ",") } + metadataTokens.sorted() + issues.map { String(describing: $0) } return RulesCollection( version: String(ProxyACLDocument.digest(tokens.sorted().joined(separator: "\n")).prefix(16)), - rows: rows, sources: sources, issues: issues) + rows: rows, sources: sources, issues: issues, userDocument: userDocument) } private func row( identity: RuleIdentity, entries: [RulesCandidate], index: RulesOverlapIndex @@ -144,7 +161,8 @@ private struct RulesCollectionInput { return RulesRow( id: .rule(identity), identity: identity, content: identity.match.browsingContent, sources: memberships, customIDs: Set(entries.compactMap { $0.customID }), - relationships: relationships, fixedCoverage: fixedCoverage) + relationships: disabled.contains(identity) ? [] : relationships, fixedCoverage: fixedCoverage, + isEnabled: memberships.contains(.fixed) || !disabled.contains(identity)) } } diff --git a/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift b/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift index d2f8b740..607b343c 100644 --- a/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift @@ -34,7 +34,7 @@ final class BuiltinRuleCatalogTests: XCTestCase { rules.contains { $0.match == .domainSuffix("cn") }, "快照应含 .cn 后缀直连规则") - // .cn 吸收后不应再有独立 .cn 域名条目。 + // 完整候选保留可恢复的独立 .cn 域名条目。 let independentCN = rules.filter { switch $0.match { case .domainSuffix(let value): return value.hasSuffix(".cn") && value != "cn" @@ -42,7 +42,7 @@ final class BuiltinRuleCatalogTests: XCTestCase { default: return false } } - XCTAssertTrue(independentCN.isEmpty, "同动作 .cn 域名应已被吸收") + XCTAssertFalse(independentCN.isEmpty, "候选保留被吸收的规则以便禁用宽规则后恢复") } /// china-ipv4 快照(issue #64):从 bundle 或源码树加载,直连候选为 IPv4 CIDR。 diff --git a/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift index ce17b18d..62a4a7a8 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift @@ -5,7 +5,7 @@ import XCTest /// 自定义规则变更的完整重启与失败回滚(issue #66 AC1/AC4)。 extension ProxyRuntimeControllerTests { - private func makeCustomRuleStore() throws -> (store: CustomRuleStore, directory: URL) { + func makeCustomRuleStore() throws -> (store: CustomRuleStore, directory: URL) { let directory = runtime.directory.appendingPathComponent( "custom-rules-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -13,7 +13,7 @@ extension ProxyRuntimeControllerTests { return (store, directory) } - private func makeControllerWithCustomRules( + func makeControllerWithCustomRules( store: CustomRuleStore, probe: EndpointProbing = ProxyRuntimeFixture.FakeProbe.reachable(), settings: ProxySettings? = nil, @@ -31,7 +31,7 @@ extension ProxyRuntimeControllerTests { } /// 从链接解析读取当前 ACL 变体内容(契约不再内嵌 content)。 - private func activeACLContent(_ store: RuntimeFileStore) throws -> String { + func activeACLContent(_ store: RuntimeFileStore) throws -> String { let data = try Data(contentsOf: store.aclFileURL) return String(bytes: data, encoding: .utf8) ?? "" } diff --git a/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift index 655698a2..7a4cc09a 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift @@ -21,6 +21,25 @@ final class CustomRuleStoreTests: XCTestCase { try super.tearDownWithError() } + func testSchemaOneMigratesAndDisablementRoundTripsInSameDocument() throws { + let rule = CustomRule(action: .direct, match: .domainExact("private.example")) + let legacy = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": 1, + "rules": JSONSerialization.jsonObject(with: JSONEncoder().encode([rule])), + ]) + try legacy.write(to: store.fileURL) + let migrated = try store.loadDocument() + XCTAssertEqual(migrated.rules, [rule]) + XCTAssertTrue(migrated.disabledIdentities.isEmpty) + try store.saveDocument( + CustomRuleDocument(rules: migrated.rules, disabledIdentities: [rule.identity])) + let saved = try store.loadDocument() + XCTAssertEqual(saved.schemaVersion, 2) + XCTAssertEqual(saved.disabledIdentities, [rule.identity]) + XCTAssertNotEqual( + try store.summary().contentVersion, CustomRuleSummary.summarizing([rule]).contentVersion) + } + func testMissingFileLoadsAsEmpty() throws { XCTAssertEqual(try store.load(), []) } @@ -63,7 +82,9 @@ final class CustomRuleStoreTests: XCTestCase { func testMalformedStoredCIDRReportsCorruptionWithoutRewritingDocument() throws { for match in [RuleMatch.ipv4CIDR("/"), .ipv6CIDR("/")] { - try store.save([CustomRule(action: .direct, match: match)]) + try CustomRuleStore.encode( + CustomRuleDocument(rules: [CustomRule(action: .direct, match: match)]) + ).write(to: store.fileURL) let before = try Data(contentsOf: store.fileURL) XCTAssertThrowsError(try store.load()) { error in guard case .corrupt = error as? CustomRuleStoreError else { diff --git a/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift b/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift new file mode 100644 index 00000000..41aec819 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift @@ -0,0 +1,166 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +extension ProxyRuntimeControllerTests { + func testBatchDisableRestartsOnceAndRestoresAbsorbedChinaCandidate() async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let settings = ProxySettings( + listen: ActivationFixture.listen, preferredMode: .rule, + ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true) + let controller = makeControllerWithCustomRules( + store: store, settings: settings, proxyMode: .rule) + try await controller.activate(seeded.server) + let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) + let before = agent.unregisterCount + let cn = RuleIdentity(action: .direct, match: .domainSuffix("cn")) + let absent = RuleIdentity(action: .direct, match: .domainExact("absent.example")) + let outcome = await controller.updateRuleDocument( + CustomRuleDocument(rules: [], disabledIdentities: [cn, absent])) + XCTAssertEqual(outcome, .saved) + XCTAssertEqual(agent.unregisterCount, before + 1) + let content = try activeACLContent(runtimeStore) + XCTAssertFalse(content.split(separator: "\n").contains("||cn")) + let snapshot = try BuiltinRuleCatalog.loadGeolocationCN(from: AppArtifact.bundle) + let narrow = try XCTUnwrap(snapshot.absorbed.first) + XCTAssertTrue(content.contains(narrow.aclLine)) + let noOpBefore = agent.unregisterCount + let unchanged = await controller.updateRuleDocument( + CustomRuleDocument(rules: [], disabledIdentities: [cn])) + XCTAssertEqual(unchanged, .saved) + XCTAssertEqual(agent.unregisterCount, noOpBefore, "Absent identities change only persistence") + } + + func testDisablingGFWBlockersRestoresPreservedExpressibleException() async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let settings = ProxySettings( + listen: ActivationFixture.listen, preferredMode: .rule, + ruleDefaultAction: .directWhenUnmatched, agentEnabled: true) + let controller = makeControllerWithCustomRules( + store: store, settings: settings, proxyMode: .rule) + try await controller.activate(seeded.server) + let snapshot = try BuiltinRuleCatalog.loadGFWList(from: AppArtifact.bundle) + let exception = try XCTUnwrap(snapshot.absorbed.first) + let blockers = Set( + (snapshot.rules + snapshot.absorbed).filter { + $0.action == .proxy && RuleCoverage.domainCovers($0.match, exception.match) + }.map(\.identity)) + XCTAssertFalse(blockers.isEmpty) + let before = agent.unregisterCount + let outcome = await controller.updateRuleDocument( + CustomRuleDocument(rules: [], disabledIdentities: blockers)) + XCTAssertEqual(outcome, .saved) + XCTAssertEqual(agent.unregisterCount, before + 1) + let candidates = try controller.ruleModeCandidateRules() + XCTAssertTrue( + candidates.contains { + $0.action == .direct && RuleCoverage.domainCovers($0.match, exception.match) + }) + } + + func testDisableWhileOffPersistsWithoutStartingAndCorruptDocumentIsPreserved() async throws { + let (store, _) = try makeCustomRuleStore() + let controller = makeControllerWithCustomRules( + store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false)) + let identity = RuleIdentity(action: .proxy, match: .domainExact("saved.example")) + let outcome = await controller.updateRuleDocument( + CustomRuleDocument(rules: [], disabledIdentities: [identity])) + XCTAssertEqual(outcome, .saved) + XCTAssertEqual(agent.registerCount, 0) + XCTAssertEqual(try store.loadDocument().disabledIdentities, [identity]) + let broken = Data("broken".utf8) + try broken.write(to: store.fileURL) + let rejected = await controller.updateRuleDocument(CustomRuleDocument(rules: [])) + XCTAssertEqual(rejected, .persistenceFailed) + XCTAssertEqual(try Data(contentsOf: store.fileURL), broken) + XCTAssertEqual(agent.registerCount, 0) + } + + func testAgentOffDuringRecoveryCannotBeOverwrittenByOldRuleTransaction() async throws { + try await interruptRuleRecovery(switchMode: false) + } + + func testModeSwitchDuringRecoveryCannotBeOverwrittenByOldRuleTransaction() async throws { + try await interruptRuleRecovery(switchMode: true) + } + + private func interruptRuleRecovery(switchMode: Bool) async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let rule = CustomRule(action: .direct, match: .domainExact("kept.example")) + try store.save([rule]) + let settings = ProxySettings( + listen: ActivationFixture.listen, preferredMode: .rule, + ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true) + let controller = makeControllerWithCustomRules( + store: store, settings: settings, proxyMode: .rule, + launchHealthTimeoutSeconds: 1) + try await controller.activate(seeded.server) + let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) + let recoveryStarted = expectation(description: "recovery awaiting health") + let initialRegistrations = agent.registerCount + agent.onRegister = { [agent] in + if agent?.registerCount == initialRegistrations + 2 { + recoveryStarted.fulfill() + } + if (agent?.registerCount ?? 0) <= initialRegistrations + 2 { + try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL) + } else if let document = runtimeStore.loadDocument() { + try? runtimeStore.writeRuntimeReceipt(for: document, processID: 42) + } + } + let pending = Task { + await controller.updateRuleDocument( + CustomRuleDocument( + rules: [rule], + disabledIdentities: [rule.identity])) + } + await fulfillment(of: [recoveryStarted], timeout: 5) + if switchMode { + await controller.setProxyMode(.global) + } else { + await controller.setAgentEnabled(false) + } + let registrations = agent.registerCount + let outcome = await pending.value + guard case .recoveryFailed = outcome else { + return XCTFail("Interrupted recovery must not report success: \(outcome)") + } + XCTAssertEqual(agent.registerCount, registrations) + if switchMode { + XCTAssertEqual(controller.proxyMode, .global) + XCTAssertEqual(runtimeStore.loadDocument()?.aclRuntime?.summary, "global") + } else { + XCTAssertFalse(controller.settings.agentEnabled) + XCTAssertEqual(controller.state, .off) + XCTAssertNil(runtimeStore.loadDocument()) + } + } + + func testFailedDisableAndFailedRuntimeRecoveryReportsFailure() async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let rule = CustomRule(action: .direct, match: .domainExact("kept.example")) + try store.save([rule]) + let settings = ProxySettings( + listen: ActivationFixture.listen, preferredMode: .rule, + ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true) + let controller = makeControllerWithCustomRules( + store: store, settings: settings, proxyMode: .rule) + try await controller.activate(seeded.server) + let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) + agent.onRegister = { + try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL) + } + let result = await controller.updateRuleDocument( + CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity])) + guard case .recoveryFailed(let detail) = result else { + return XCTFail("Expected actual recovery failure, got \(result)") + } + XCTAssertFalse(detail.isEmpty) + XCTAssertEqual(try store.loadDocument().disabledIdentities, []) + XCTAssertNotEqual(controller.state, .running) + } +} diff --git a/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift b/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift new file mode 100644 index 00000000..f65db719 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift @@ -0,0 +1,73 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +final class RuleEnablementWorkflowTests: XCTestCase { + func testInFlightBatchRejectsRepeatedCommandAndPublishesProgress() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json")) + let rule = CustomRule(action: .proxy, match: .domainExact("example.net")) + try store.save([rule]) + let started = expectation(description: "commit started") + var continuation: CheckedContinuation? + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { try store.loadDocument() }, + commitDocument: { document in + commits += 1 + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + do { + try store.saveDocument(document) + return .saved + } catch { + XCTFail("Fixture save failed: \(error)") + return .persistenceFailed + } + }, + loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) } + await fulfillment(of: [started], timeout: 3) + XCTAssertTrue(workflow.snapshot.isCommitting) + await workflow.setEnabled(false, identities: [rule.identity]) + await workflow.refresh() + XCTAssertEqual(commits, 1) + continuation?.resume() + await pending.value + XCTAssertFalse(workflow.snapshot.isCommitting) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) + XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled) + } + + func testFailedSaveKeepsSnapshotAndExternalDocumentChangeRejectsStaleCommand() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json")) + let rule = CustomRule(action: .direct, match: .domainExact("kept.example")) + try store.save([rule]) + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { try store.loadDocument() }, + commitDocument: { _ in + commits += 1 + return .persistenceFailed + }, + loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + let version = workflow.snapshot.version + await workflow.setEnabled(false, identities: [rule.identity]) + XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed) + XCTAssertEqual(workflow.snapshot.version, version) + XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled) + try store.saveDocument(CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity])) + await workflow.setEnabled(false, identities: [rule.identity]) + XCTAssertEqual(workflow.snapshot.commitOutcome, .versionConflict) + XCTAssertEqual(commits, 1) + XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled) + } +} diff --git a/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift b/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift index d31c57ac..bbd864fe 100644 --- a/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift @@ -4,6 +4,62 @@ import XCTest @MainActor final class RulesWorkflowTests: XCTestCase { + func testDisableAcrossSourcesRestoresCoverageAndOrphanCanBeEnabled() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json")) + let broad = CustomRule(action: .proxy, match: .domainSuffix("example.com")) + let narrow = CustomRule(action: .direct, match: .domainExact("safe.example.com")) + let orphan = RuleIdentity(action: .proxy, match: .domainExact("gone.example")) + try store.saveDocument( + CustomRuleDocument(rules: [broad, narrow], disabledIdentities: [orphan])) + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { try store.loadDocument() }, + commitDocument: { document in + commits += 1 + do { + try store.saveDocument(document) + return .saved + } catch { + XCTFail("Fixture save failed: \(error)") + return .persistenceFailed + } + }, + loadBuiltin: { source in + guard source == .gfwlist else { return rulesFixture(source) } + let identity = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "fixture") + return rulesFixture( + source, + rules: [ + ProxyRule(action: .proxy, match: broad.match, source: identity) + ]) + }) + await workflow.refresh() + let version = workflow.snapshot.version + let covered = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == narrow.identity }) + XCTAssertTrue(covered.relationships.contains { $0.kind == .shadowing && $0.extent == .full }) + workflow.select([.rule(broad.identity), .noDotHostname]) + XCTAssertEqual(workflow.actionableSelection, [broad.identity]) + await workflow.setEnabled(false, identities: workflow.actionableSelection) + XCTAssertEqual(commits, 1) + XCTAssertNotEqual(workflow.snapshot.version, version) + let disabled = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == broad.identity }) + XCTAssertFalse(disabled.isEnabled) + XCTAssertEqual(disabled.sources, [.custom, .gfwlist]) + XCTAssertTrue( + workflow.snapshot.rows.first { $0.identity == narrow.identity }!.relationships.isEmpty) + workflow.setTestTarget("safe.example.com") + await workflow.testAddress() + XCTAssertEqual(workflow.snapshot.addressTest.result?.outcome, .direct) + workflow.query(RulesQuery(enabled: false)) + XCTAssertTrue(workflow.snapshot.rows.contains { $0.identity == orphan && !$0.hasCurrentSource }) + await workflow.setEnabled(true, identities: [orphan]) + XCTAssertFalse(workflow.snapshot.rows.contains { $0.identity == orphan }) + XCTAssertNil(workflow.snapshot.addressTest.result) + XCTAssertFalse(try store.loadDocument().disabledIdentities.contains(orphan)) + } + func testEquivalentSourcesMergeAndAbsorbedCandidatesRemainBrowsable() async throws { let match = try RuleMatch(domainExact: "Example.COM") let custom = CustomRule(action: .direct, match: match)