diff --git a/CONTEXT.md b/CONTEXT.md index 8e751a41..84224afa 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,6 +113,6 @@ - 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. - Runtime file deployment prepares contract bytes once and still reads the actual disk contract before choosing actions. ACL digest-manifest hits avoid reading the variant body; a miss reads the existing variant once and reuses those bytes if rollback is needed. An unchanged active-link target is preserved. The synchronous transaction records only completed mutations and attempts every recorded restoration on failure, reporting any restoration failure explicitly. A decoded contract with no ACL body can restore only an existing regular variant file; a missing or unsafe variant fails before publishing its link or contract. Digest metadata is updated only after contract persistence succeeds. -- 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 retrying 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 return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The page has no delete action yet. +- 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 retrying 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 return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The operation area counts selected custom memberships and offers single or batch deletion. Workflow preparation captures an immutable custom UUID set and collection version for one confirmation; filtering clears hidden selections, and later selection changes cannot expand the captured set. Confirmation rejects stale or incomplete facts and uses the same whole-document transaction, retaining builtin memberships and all disabled identities, including orphan records. Success rebuilds source counts, relationships and selection and invalidates the address test; unchanged ACLs do not restart runtime. Cancellation only discards the confirmation. - 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/Rules.xcstrings b/ShadowsocksX-NG2/App/Rules.xcstrings index 5b507cd9..a74a7e33 100644 --- a/ShadowsocksX-NG2/App/Rules.xcstrings +++ b/ShadowsocksX-NG2/App/Rules.xcstrings @@ -1202,6 +1202,72 @@ } } } + }, + "删除 %lld 条自定义规则…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete %lld Custom Rules…" + } + } + } + }, + "删除 %lld 条自定义规则?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete %lld Custom Rules?" + } + } + } + }, + "只删除所选自定义条目。其他来源的规则和已有禁用记录将保留。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only the selected custom entries will be deleted. Rules from other sources and existing disablement records will be kept." + } + } + } + }, + "删除": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, + "已删除 %lld 条自定义规则": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deleted %lld Custom Rules" + } + } + } + }, + "规则集合已变化,请重新确认删除。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The rule collection has changed. Confirm the deletion again." + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/RulesDeletionButton.swift b/ShadowsocksX-NG2/App/RulesDeletionButton.swift new file mode 100644 index 00000000..a449066d --- /dev/null +++ b/ShadowsocksX-NG2/App/RulesDeletionButton.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// A single confirmation captures custom UUIDs before starting the shared transaction. +struct RulesDeletionButton: View { + @ObservedObject var workflow: RulesWorkflow + @State private var confirmation: CustomRuleDeletion? + @State private var failure: CustomRuleDeletionResult.Failure? + + var body: some View { + Button { + confirmation = workflow.prepareCustomRuleDeletion() + } label: { + Text(verbatim: countText("删除 %lld 条自定义规则…", workflow.deletableSelection.count)) + } + .disabled( + workflow.deletableSelection.isEmpty || !workflow.snapshot.isComplete + || workflow.snapshot.isCommitting + ) + .alert( + countText("删除 %lld 条自定义规则?", confirmation?.customIDs.count ?? 0), + isPresented: Binding( + get: { confirmation != nil }, set: { if !$0 { confirmation = nil } }), + presenting: confirmation + ) { captured in + Button(RulesCopy.text("删除"), role: .destructive) { + confirmation = nil + Task { + if case .unavailable(let reason) = await workflow.deleteCustomRules(captured) { + failure = reason + } + } + } + Button(RulesCopy.text("取消"), role: .cancel) { confirmation = nil } + } message: { _ in + Text(RulesCopy.text("只删除所选自定义条目。其他来源的规则和已有禁用记录将保留。")) + } + .alert( + RulesCopy.text("无法更新规则"), + isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } }) + ) { + Button(RulesCopy.text("关闭")) { failure = nil } + } message: { + Text(RulesCopy.text(failureMessage)) + } + } + + private func countText(_ key: String, _ count: Int) -> String { + String.localizedStringWithFormat(RulesCopy.text(key), Int64(count)) + } + + private var failureMessage: String { + switch failure { + case .staleConfirmation: "规则集合已变化,请重新确认删除。" + case .incompleteCollection: "集合不完整" + case .busy, nil: "正在更新规则…" + } + } +} diff --git a/ShadowsocksX-NG2/App/RulesOperationStatusView.swift b/ShadowsocksX-NG2/App/RulesOperationStatusView.swift index a7a9fd2e..36bfa74f 100644 --- a/ShadowsocksX-NG2/App/RulesOperationStatusView.swift +++ b/ShadowsocksX-NG2/App/RulesOperationStatusView.swift @@ -118,6 +118,9 @@ extension RulesCommitFeedback { RulesCopy.text(enabled ? "已启用 %lld 条规则" : "已禁用 %lld 条规则"), Int64(changedCount)) case .add: count = RulesCopy.text("已新增规则") case .edit: count = RulesCopy.text("已编辑规则") + case .delete: + count = String.localizedStringWithFormat( + RulesCopy.text("已删除 %lld 条自定义规则"), Int64(changedCount)) } let application: String switch outcome { diff --git a/ShadowsocksX-NG2/App/RulesView.swift b/ShadowsocksX-NG2/App/RulesView.swift index 2e9bc631..a88cd6a6 100644 --- a/ShadowsocksX-NG2/App/RulesView.swift +++ b/ShadowsocksX-NG2/App/RulesView.swift @@ -29,7 +29,10 @@ struct RulesView: View { VStack(spacing: 0) { VStack(spacing: 6) { filters - RulesOperationStatusView(workflow: workflow, onShowRuntime: onShowRuntime) + HStack { + RulesDeletionButton(workflow: workflow) + RulesOperationStatusView(workflow: workflow, onShowRuntime: onShowRuntime) + } }.padding() ruleTable.overlay { emptyState } if let row = workflow.selectedRelationshipRow { diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift index fec23d45..2a5163f1 100644 --- a/ShadowsocksX-NG2/App/RulesWorkflow.swift +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -291,3 +291,43 @@ extension RulesWorkflow { return .committed(await commit(document, operation: draft.editingID == nil ? .add : .edit)) } } + +/// Deletion targets custom memberships, never the shared match-and-action identity. +extension RulesWorkflow { + var deletableSelection: Set { + Set(snapshot.rows.filter { snapshot.selection.contains($0.id) }.flatMap(\.customIDs)) + } + + func prepareCustomRuleDeletion() -> CustomRuleDeletion? { + guard snapshot.isComplete, !snapshot.isCommitting, commitDocument != nil, + !deletableSelection.isEmpty + else { return nil } + return CustomRuleDeletion(customIDs: deletableSelection, version: snapshot.version) + } + + func deleteCustomRules(_ confirmation: CustomRuleDeletion) async -> CustomRuleDeletionResult { + guard !snapshot.isCommitting else { return .unavailable(.busy) } + guard snapshot.isComplete, commitDocument != nil, let old = collection?.userDocument + else { return .unavailable(.incompleteCollection) } + guard confirmation.version == snapshot.version, + !confirmation.customIDs.isEmpty, + confirmation.customIDs.isSubset(of: Set(old.rules.map(\.id))) + else { return .unavailable(.staleConfirmation) } + let document = CustomRuleDocument( + rules: old.rules.filter { !confirmation.customIDs.contains($0.id) }, + disabledIdentities: old.disabledIdentities) + return .committed( + await commit( + document, operation: .delete, changedCount: confirmation.customIDs.count)) + } +} + +struct CustomRuleDeletion: Equatable, Sendable { + let customIDs: Set + let version: String + + fileprivate init(customIDs: Set, version: String) { + self.customIDs = customIDs + self.version = version + } +} diff --git a/ShadowsocksX-NG2/Domain/CustomRuleDeletion.swift b/ShadowsocksX-NG2/Domain/CustomRuleDeletion.swift new file mode 100644 index 00000000..76bb920f --- /dev/null +++ b/ShadowsocksX-NG2/Domain/CustomRuleDeletion.swift @@ -0,0 +1,5 @@ +enum CustomRuleDeletionResult: Equatable, Sendable { + enum Failure: Equatable, Sendable { case busy, incompleteCollection, staleConfirmation } + case unavailable(Failure) + case committed(CustomRuleUpdateOutcome) +} diff --git a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift index 96da6ee5..03cc6108 100644 --- a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift +++ b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift @@ -76,7 +76,7 @@ enum RulesOperationStatus: Equatable, Sendable { struct RulesCommitFeedback: Equatable, Sendable, Identifiable { enum Operation: Equatable, Sendable { case enablement(Bool) - case add, edit + case add, edit, delete } let id = UUID() let outcome: CustomRuleUpdateOutcome diff --git a/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift new file mode 100644 index 00000000..dfe5fe69 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift @@ -0,0 +1,178 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +extension ProxyRuntimeControllerTests { + func testDeletionWhileOffPersistsBatchAndDisabledOrphansWithoutStarting() async throws { + let (store, _) = try makeCustomRuleStore() + let rules = [ + CustomRule(action: .direct, match: .domainExact("one.example")), + CustomRule(action: .direct, match: .domainExact("two.example")), + ] + let original = CustomRuleDocument(rules: rules, disabledIdentities: [rules[0].identity]) + try store.saveDocument(original) + let controller = makeControllerWithCustomRules( + store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false)) + let workflow = deletionWorkflow(controller) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .committed(.saved)) + XCTAssertEqual( + try store.loadDocument(), + CustomRuleDocument(rules: [], disabledIdentities: original.disabledIdentities)) + XCTAssertEqual(controller.state, .off) + XCTAssertEqual(agent.registerCount, 0) + var query = workflow.snapshot.query + query.enabled = false + workflow.query(query) + let orphan = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == rules[0].identity }) + XCTAssertFalse(orphan.hasCurrentSource) + await workflow.setEnabled(true, identities: [rules[0].identity]) + XCTAssertFalse(workflow.snapshot.rows.contains { $0.identity == rules[0].identity }) + XCTAssertTrue(try store.loadDocument().disabledIdentities.isEmpty) + } + + func testBatchDeletionDeploysOnceAndInvalidatesAddressTest() async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let rules = [ + CustomRule(action: .direct, match: .domainExact("one-delete.example")), + CustomRule(action: .direct, match: .domainExact("two-delete.example")), + ] + try store.save(rules) + let controller = makeControllerWithCustomRules( + store: store, + settings: ProxySettings( + listen: ActivationFixture.listen, preferredMode: .rule, + ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule) + try await controller.activate(seeded.server) + let workflow = deletionWorkflow(controller) + await workflow.refresh() + workflow.setTestTarget("one-delete.example") + await workflow.testAddress() + XCTAssertNotNil(workflow.snapshot.addressTest.result) + workflow.select(Set(workflow.snapshot.rows.filter { !$0.customIDs.isEmpty }.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + let registrations = agent.registerCount + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .committed(.applied)) + XCTAssertEqual(agent.registerCount, registrations + 1) + XCTAssertTrue(try store.load().isEmpty) + let content = try activeACLContent(RuntimeFileStore(fileURL: runtime.contract)) + XCTAssertFalse(content.contains("one-delete.example")) + XCTAssertFalse(content.contains("two-delete.example")) + XCTAssertNil(workflow.snapshot.addressTest.result) + XCTAssertTrue(workflow.snapshot.selection.isEmpty) + XCTAssertEqual(workflow.snapshot.commitFeedback?.operation, .delete) + } + + func testDeletionInNonRuleModesOnlySavesAndCoveredRuleDoesNotRestart() async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let rule = CustomRule(action: .direct, match: .domainExact("covered.cn")) + for mode in [ProxyMode.global, .direct, .rule] { + try store.save([rule]) + let controller = makeControllerWithCustomRules( + store: store, + settings: ProxySettings( + listen: ActivationFixture.listen, preferredMode: mode.kind, + ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: mode) + try await controller.activate(seeded.server) + let workflow = deletionWorkflow(controller) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.filter { !$0.customIDs.isEmpty }.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + let registrations = agent.registerCount + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .committed(mode == .rule ? .runtimeUnchanged : .saved)) + XCTAssertEqual(agent.registerCount, registrations) + XCTAssertTrue(try store.load().isEmpty) + await controller.setAgentEnabled(false) + } + } + + func testDeletionFailureRestoresWholeDocumentAndReportsRuntimeRecoveryFailure() async throws { + let seeded = try makeSeededCatalog() + let (store, _) = try makeCustomRuleStore() + let rules = [ + CustomRule(action: .direct, match: .domainExact("keep-one.example")), + CustomRule(action: .direct, match: .domainExact("keep-two.example")), + ] + let orphan = RuleIdentity(action: .proxy, match: .domainExact("orphan.example")) + let original = CustomRuleDocument(rules: rules, disabledIdentities: [orphan]) + try store.saveDocument(original) + let controller = makeControllerWithCustomRules( + store: store, + settings: ProxySettings( + listen: ActivationFixture.listen, preferredMode: .rule, + ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true), proxyMode: .rule) + try await controller.activate(seeded.server) + let workflow = deletionWorkflow(controller) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.filter { !$0.customIDs.isEmpty }.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) + let previousRuntime = try XCTUnwrap(runtimeStore.loadDocument()) + let previousACL = try activeACLContent(runtimeStore) + agent.onRegister = { + guard let requested = runtimeStore.loadDocument() else { return } + let isRecovery = (try? store.load().count) == rules.count + try? runtimeStore.writeRuntimeReceipt( + for: isRecovery ? requested : previousRuntime, processID: 42) + } + let rolledBack = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(rolledBack, .committed(.rolledBack)) + XCTAssertEqual(try store.loadDocument(), original) + XCTAssertEqual(runtimeStore.loadDocument(), previousRuntime) + XCTAssertEqual(try activeACLContent(runtimeStore), previousACL) + XCTAssertEqual(workflow.snapshot.version, confirmation.version) + XCTAssertEqual(controller.state, .running) + agent.onRegister = { + try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL) + } + let failed = await workflow.deleteCustomRules(confirmation) + guard case .committed(.recoveryFailed(let detail, let restored)) = failed else { + return XCTFail("Expected explicit recovery failure, got \(failed)") + } + XCTAssertTrue(restored) + XCTAssertFalse(detail.isEmpty) + XCTAssertEqual(try store.loadDocument(), original) + XCTAssertNotEqual(controller.state, .running) + XCTAssertEqual( + workflow.snapshot.commitOutcome, .recoveryFailed(detail: detail, rulesRestored: restored)) + } + + func testDeletionPersistenceFailureRetainsOwnedDocumentAndDoesNotStartRuntime() async throws { + let (store, directory) = try makeCustomRuleStore() + let rule = CustomRule(action: .direct, match: .domainExact("kept.example")) + let original = CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]) + try store.saveDocument(original) + let controller = makeControllerWithCustomRules( + store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false)) + let workflow = deletionWorkflow(controller) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + let originalData = try Data(contentsOf: store.fileURL) + let backupDirectory = directory.appendingPathExtension("backup") + try FileManager.default.moveItem(at: directory, to: backupDirectory) + try Data("blocks parent directory".utf8).write(to: directory) + let backup = backupDirectory.appendingPathComponent(store.fileURL.lastPathComponent) + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .committed(.persistenceFailed)) + XCTAssertEqual(controller.ruleDocuments.current, original) + XCTAssertEqual(try Data(contentsOf: backup), originalData) + XCTAssertEqual(workflow.snapshot.version, confirmation.version) + XCTAssertEqual(workflow.deletableSelection, [rule.id]) + XCTAssertEqual(agent.registerCount, 0) + } + + private func deletionWorkflow(_ controller: ProxyRuntimeController) -> RulesWorkflow { + RulesWorkflow( + loadDocument: { try controller.ruleDocuments.load() }, + commitDocument: { await controller.commitRuleDocument($0) }, + builtinSnapshots: controller.ruleSnapshots) + } +} diff --git a/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift new file mode 100644 index 00000000..102f4c3f --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift @@ -0,0 +1,158 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +final class CustomRuleDeletionWorkflowTests: XCTestCase { + func testMixedSelectionDeletesOnlyCustomUUIDsAndPreservesDisablementAndBuiltinMembership() + async throws + { + let custom = CustomRule(action: .proxy, match: .domainSuffix("merged.example")) + let other = CustomRule(action: .direct, match: .domainExact("other.example")) + let orphan = RuleIdentity(action: .proxy, match: .domainExact("orphan.example")) + let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "GFWList") + let builtin = ProxyRule(action: custom.action, match: custom.match, source: source) + var saved = CustomRuleDocument( + rules: [custom, other], disabledIdentities: [custom.identity, orphan]) + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { saved }, + commitDocument: { + commits += 1 + saved = $0 + return RuleDocumentCommit(outcome: .saved, document: saved) + }, + loadBuiltin: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) }) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + XCTAssertEqual(workflow.deletableSelection, [custom.id, other.id]) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + XCTAssertEqual(confirmation.customIDs, [custom.id, other.id]) + XCTAssertEqual(commits, 0, "Preparing and discarding a confirmation has no side effects") + let oldVersion = workflow.snapshot.version + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .committed(.saved)) + XCTAssertEqual(commits, 1) + XCTAssertTrue(saved.rules.isEmpty) + XCTAssertEqual(saved.disabledIdentities, [custom.identity, orphan]) + let merged = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == custom.identity }) + XCTAssertEqual(merged.sources, [.gfwlist]) + XCTAssertTrue(merged.customIDs.isEmpty) + XCTAssertFalse(merged.isEnabled) + XCTAssertNotEqual(workflow.snapshot.version, oldVersion) + XCTAssertFalse(workflow.snapshot.rows.contains { $0.identity == other.identity }) + XCTAssertTrue(workflow.snapshot.selection.isSubset(of: Set(workflow.snapshot.rows.map(\.id)))) + XCTAssertEqual(workflow.snapshot.commitFeedback?.changedCount, 2) + } +} + +extension CustomRuleDeletionWorkflowTests { + func testFilteredSelectionCannotEnterConfirmationAndStaleConfirmationCannotExpand() async throws { + let first = CustomRule(action: .proxy, match: .domainExact("first.example")) + let hidden = CustomRule(action: .direct, match: .domainExact("hidden.example")) + var commits = 0 + var saved = CustomRuleDocument(rules: [first, hidden]) + let workflow = RulesWorkflow( + loadDocument: { saved }, + commitDocument: { + commits += 1 + saved = $0 + return RuleDocumentCommit(outcome: .saved, document: saved) + }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + var query = workflow.snapshot.query + query.search = "first" + workflow.query(query) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + XCTAssertEqual(confirmation.customIDs, [first.id]) + query.search = "" + workflow.query(query) + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + let deleted = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(deleted, .committed(.saved)) + XCTAssertEqual(saved.rules.map(\.id), [hidden.id], "New selection cannot expand captured UUIDs") + let stale = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(stale, .unavailable(.staleConfirmation)) + XCTAssertEqual(commits, 1) + workflow.select([]) + XCTAssertNil(workflow.prepareCustomRuleDeletion()) + } + + func testVersionChangeRejectsDeletionEvenWhenTheCustomUUIDStillExists() async throws { + let rule = CustomRule(action: .proxy, match: .domainExact("kept.example")) + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { CustomRuleDocument(rules: [rule]) }, + commitDocument: { + commits += 1 + return RuleDocumentCommit(outcome: .saved, document: $0) + }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + await workflow.setEnabled(false, identities: [rule.identity]) + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .unavailable(.staleConfirmation)) + XCTAssertEqual(commits, 1) + XCTAssertEqual(workflow.deletableSelection, [rule.id]) + } + + func testPendingDeletionBlocksRepeatsAndPersistenceFailureKeepsConfirmationRetryable() + async throws + { + let rule = CustomRule(action: .proxy, match: .domainExact("kept.example")) + let original = CustomRuleDocument(rules: [rule]) + let started = expectation(description: "delete awaiting commit") + var continuation: CheckedContinuation? + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { original }, + commitDocument: { _ in + commits += 1 + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + return RuleDocumentCommit(outcome: .persistenceFailed, document: original) + }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + let pending = Task { await workflow.deleteCustomRules(confirmation) } + await fulfillment(of: [started], timeout: 3) + XCTAssertNil(workflow.prepareCustomRuleDeletion()) + let repeated = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(repeated, .unavailable(.busy)) + continuation?.resume() + let result = await pending.value + XCTAssertEqual(result, .committed(.persistenceFailed)) + XCTAssertEqual(commits, 1) + XCTAssertEqual(workflow.snapshot.version, confirmation.version) + XCTAssertEqual(workflow.prepareCustomRuleDeletion(), confirmation) + } + + func testIncompleteCollectionRejectsDeletionFromPreviouslyLoadedFacts() async throws { + let rule = CustomRule(action: .proxy, match: .domainExact("kept.example")) + var failed = false + var commits = 0 + let workflow = RulesWorkflow( + loadDocument: { + if failed { throw CustomRuleStoreError.corrupt(detail: "fixture") } + return CustomRuleDocument(rules: [rule]) + }, + commitDocument: { + commits += 1 + return RuleDocumentCommit(outcome: .saved, document: $0) + }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.select(Set(workflow.snapshot.rows.map(\.id))) + let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) + failed = true + await workflow.refresh() + XCTAssertNil(workflow.prepareCustomRuleDeletion()) + let result = await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(result, .unavailable(.incompleteCollection)) + XCTAssertEqual(commits, 0) + } +}