diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift index 2399beeb..addfab0d 100644 --- a/ShadowsocksX-NG2/App/RulesWorkflow.swift +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -31,15 +31,7 @@ final class RulesWorkflow: ObservableObject { analysisDelay: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(150)) }, - builtinSnapshots: BuiltinRuleSnapshots? = nil, - loadBuiltin: @escaping @Sendable (RulesSource) throws -> RuleSnapshot = { source in - switch source { - case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN() - case .chinaIPv4: try BuiltinRuleCatalog.loadChinaIPv4() - case .gfwlist: try BuiltinRuleCatalog.loadGFWList() - case .custom, .fixed: throw RuleSnapshotError.missing - } - } + builtinSnapshots: BuiltinRuleSnapshots = BuiltinRuleSnapshots() ) { if let loadDocument { self.loadDocument = loadDocument @@ -52,7 +44,7 @@ final class RulesWorkflow: ObservableObject { self.feedbackDelay = feedbackDelay self.analysisDelay = analysisDelay self.loadCustom = loadCustom ?? { try CustomRuleStore().load() } - self.builtinSnapshots = builtinSnapshots ?? BuiltinRuleSnapshots(loader: loadBuiltin) + self.builtinSnapshots = builtinSnapshots } func refresh(retryFailedSources: Bool = false) async { diff --git a/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift b/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift index 4421885c..264bb78e 100644 --- a/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift +++ b/ShadowsocksX-NG2/Domain/BuiltinRuleCatalog.swift @@ -19,7 +19,9 @@ struct BuiltinRuleCatalog { try loadSnapshot(named: "gfwlist", from: bundle) } - private static func loadSnapshot(named name: String, from bundle: Bundle) throws -> RuleSnapshot { + fileprivate static func loadSnapshot(named name: String, from bundle: Bundle) throws + -> RuleSnapshot + { // folder reference 保留 `rules//snapshot.json` 目录结构。 guard let url = bundle.url( @@ -62,12 +64,10 @@ actor BuiltinRuleSnapshots { init(bundle: Bundle = .main, loader: Loader? = nil) { self.loader = loader ?? { source in - switch source { - case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN(from: bundle) - case .chinaIPv4: try BuiltinRuleCatalog.loadChinaIPv4(from: bundle) - case .gfwlist: try BuiltinRuleCatalog.loadGFWList(from: bundle) - case .fixed, .custom: throw RuleSnapshotError.missing + guard let kind = source.sourceKind, kind != .custom else { + throw RuleSnapshotError.missing } + return try BuiltinRuleCatalog.loadSnapshot(named: kind.rawValue, from: bundle) } } @@ -82,12 +82,8 @@ actor BuiltinRuleSnapshots { id: UUID(), task: Task.detached(priority: .userInitiated) { let snapshot = try loader(source) - let kind: RuleSourceKind - switch source { - case .geolocationCN: kind = .geolocationCN - case .chinaIPv4: kind = .chinaIPv4 - case .gfwlist: kind = .gfwlist - case .custom, .fixed: throw RuleSnapshotError.missing + guard let kind = source.sourceKind, kind != .custom else { + throw RuleSnapshotError.missing } guard snapshot.metadata.source.kind == kind else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") } diff --git a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift index a5a9604d..3dda11db 100644 --- a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift +++ b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift @@ -11,6 +11,17 @@ struct RulesQuery: Equatable, Sendable { enum RulesSource: String, CaseIterable, Identifiable, Sendable { case custom, geolocationCN, chinaIPv4, gfwlist, fixed var id: Self { self } + + /// Fixed policy has no snapshot metadata kind. + var sourceKind: RuleSourceKind? { + switch self { + case .custom: .custom + case .geolocationCN: .geolocationCN + case .chinaIPv4: .chinaIPv4 + case .gfwlist: .gfwlist + case .fixed: nil + } + } } struct RulesSourceSnapshot: Equatable, Identifiable, Sendable { diff --git a/ShadowsocksX-NG2/Domain/RulesCollection.swift b/ShadowsocksX-NG2/Domain/RulesCollection.swift index b3840c8e..ca2a730c 100644 --- a/ShadowsocksX-NG2/Domain/RulesCollection.swift +++ b/ShadowsocksX-NG2/Domain/RulesCollection.swift @@ -89,13 +89,7 @@ private struct RulesCollectionInput: Sendable { ) { do { let snapshot = try loadBuiltin(source) - let expected: RuleSourceKind - switch source { - case .geolocationCN: expected = .geolocationCN - case .chinaIPv4: expected = .chinaIPv4 - case .gfwlist: expected = .gfwlist - case .custom, .fixed: expected = .custom - } + guard let expected = source.sourceKind else { throw RuleSnapshotError.missing } guard snapshot.metadata.source.kind == expected else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") } let candidates = snapshot.rules diff --git a/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift b/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift index ea6eaa20..a30969a7 100644 --- a/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/BuiltinRuleCatalogTests.swift @@ -1,4 +1,5 @@ import Foundation +import Testing import XCTest @testable import ShadowsocksX_NG2 @@ -255,3 +256,25 @@ private final class SharedRuleSourceLoader: @unchecked Sendable { return rulesFixture(source) } } + +@Test func builtinSnapshotsRejectNonBuiltinSources() async { + let snapshots = BuiltinRuleSnapshots(loader: { _ in rulesFixture(.custom) }) + for source in [RulesSource.custom, .fixed] { + do { + _ = try await snapshots.load(source) + Issue.record("Non-builtin source must not load a snapshot") + } catch { + #expect(error as? RuleSnapshotError == .missing) + } + } +} + +@Test func builtinSnapshotsRejectMismatchedMetadata() async { + let snapshots = BuiltinRuleSnapshots(loader: { _ in rulesFixture(.chinaIPv4) }) + do { + _ = try await snapshots.load(.geolocationCN) + Issue.record("Mismatched source metadata must be rejected") + } catch { + #expect(error as? RuleSnapshotError == .corrupt(detail: "Unexpected rule source")) + } +} diff --git a/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift index f6ca4fe9..668ffc71 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift @@ -21,7 +21,9 @@ final class CustomRuleDeletionWorkflowTests: XCTestCase { saved = $0 return RuleDocumentCommit(outcome: .saved, document: saved) }, - loadBuiltin: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { + rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) + })) await workflow.refresh() workflow.select(Set(workflow.snapshot.rows.map(\.id))) XCTAssertEqual(workflow.deletableSelection, [custom.id, other.id]) @@ -57,7 +59,7 @@ extension CustomRuleDeletionWorkflowTests { commits += 1 saved = $0 return RuleDocumentCommit(outcome: .saved, document: saved) - }, loadBuiltin: { rulesFixture($0) }) + }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select(Set(workflow.snapshot.rows.map(\.id))) var query = workflow.snapshot.query @@ -86,7 +88,7 @@ extension CustomRuleDeletionWorkflowTests { commitDocument: { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) - }, loadBuiltin: { rulesFixture($0) }) + }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select(Set(workflow.snapshot.rows.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) @@ -114,7 +116,7 @@ extension CustomRuleDeletionWorkflowTests { started.fulfill() } return RuleDocumentCommit(outcome: .persistenceFailed, document: original) - }, loadBuiltin: { rulesFixture($0) }) + }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select(Set(workflow.snapshot.rows.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) @@ -143,7 +145,7 @@ extension CustomRuleDeletionWorkflowTests { commitDocument: { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) - }, loadBuiltin: { rulesFixture($0) }) + }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select(Set(workflow.snapshot.rows.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) diff --git a/ShadowsocksX-NG2/Tests/CustomRuleEditingWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleEditingWorkflowTests.swift index 4354a0f9..fbc966b3 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleEditingWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleEditingWorkflowTests.swift @@ -8,7 +8,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { let workflow = RulesWorkflow( loadDocument: { CustomRuleDocument(rules: []) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) @@ -40,7 +40,9 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { saved = $0 return RuleDocumentCommit(outcome: .saved, document: saved) }, - loadBuiltin: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { + rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) + })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: original.id)) let unchanged = await workflow.previewCustomRule(draft) @@ -71,7 +73,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { let workflow = RulesWorkflow( loadDocument: { CustomRuleDocument(rules: []) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .cidr @@ -100,7 +102,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .ipAddress @@ -134,7 +136,9 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { CustomRuleDocument(rules: [], disabledIdentities: [builtin.identity, orphan]) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { + rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) + })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.content = ".MERGE.Example" @@ -163,7 +167,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { let workflow = RulesWorkflow( loadDocument: { CustomRuleDocument(rules: []) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.action = .direct @@ -200,7 +204,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { let workflow = RulesWorkflow( loadDocument: { CustomRuleDocument(rules: [broad, proxy]) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .domainExact @@ -239,7 +243,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var discarded = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: rule.id)) discarded.content = "discarded.example" @@ -274,7 +278,7 @@ extension CustomRuleEditingWorkflowTests { } return RuleDocumentCommit(outcome: .persistenceFailed, document: original) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) @@ -304,7 +308,7 @@ extension CustomRuleEditingWorkflowTests { let workflow = RulesWorkflow( loadDocument: { original }, commitDocument: { RuleDocumentCommit(outcome: outcome, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) XCTAssertNil(workflow.makeCustomRuleDraft()) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: rule.id)) @@ -319,7 +323,7 @@ extension CustomRuleEditingWorkflowTests { let incomplete = RulesWorkflow( loadDocument: { throw CustomRuleStoreError.corrupt(detail: "fixture error") }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await incomplete.refresh() XCTAssertNil(incomplete.makeCustomRuleDraft()) let refused = await incomplete.saveCustomRule(draft) @@ -339,7 +343,7 @@ extension CustomRuleEditingWorkflowTests { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: original.id)) draft.kind = .cidr diff --git a/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift b/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift index 1692548f..0e353523 100644 --- a/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift @@ -6,7 +6,8 @@ import XCTest final class OfflineRulesWorkflowTests: XCTestCase { func testAddressTestUsesCompleteSavedCollectionDespiteBrowsingFilters() async throws { let rule = CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com")) - let workflow = RulesWorkflow(loadCustom: { [rule] }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { [rule] }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.query(RulesQuery(search: "hidden", action: .direct, source: .chinaIPv4)) XCTAssertTrue(workflow.snapshot.rows.isEmpty) @@ -19,7 +20,8 @@ final class OfflineRulesWorkflowTests: XCTestCase { XCTAssertTrue(result.domainWithoutDNS) } func testInvalidAndIncompleteCollectionsExposeOperationErrors() async throws { - let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { [] }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) workflow.setTestTarget("example.com") await workflow.testAddress() XCTAssertEqual(workflow.snapshot.addressTest.failure, .incompleteCollection) @@ -29,7 +31,8 @@ final class OfflineRulesWorkflowTests: XCTestCase { XCTAssertEqual(workflow.snapshot.addressTest.failure, .invalidTarget) XCTAssertNil(workflow.snapshot.addressTest.result) let incomplete = RulesWorkflow( - loadCustom: { [] }, loadBuiltin: { _ in throw RuleSnapshotError.missing }) + loadCustom: { [] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { _ in throw RuleSnapshotError.missing })) await incomplete.refresh() incomplete.setTestTarget("localhost") await incomplete.testAddress() @@ -44,7 +47,8 @@ final class OfflineRulesWorkflowTests: XCTestCase { let proxy = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "example.com")) try store.save([proxy]) let workflow = RulesWorkflow( - loadCustom: { try store.load() }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { try store.load() }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.setTestTarget("example.com") await workflow.testAddress() @@ -66,7 +70,8 @@ final class OfflineRulesWorkflowTests: XCTestCase { func testClearingTargetOrStartingRefreshPreventsLateTestPublication() async throws { let rule = CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com")) - let workflow = RulesWorkflow(loadCustom: { [rule] }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { [rule] }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.setTestTarget("example.com") await workflow.testAddress() @@ -102,7 +107,8 @@ final class OfflineRulesWorkflowTests: XCTestCase { CustomRule(action: .proxy, match: try RuleMatch(domainExact: "proxy.example")), CustomRule(action: .direct, match: try RuleMatch(domainExact: "direct.example")), ] - let workflow = RulesWorkflow(loadCustom: { rules }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { rules }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() for mode in [ProxyModeKind.rule, .global, .direct] { for defaultAction in RuleDefaultAction.allCases { diff --git a/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift b/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift index f4355a3c..4789f110 100644 --- a/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/RuleEnablementWorkflowTests.swift @@ -18,7 +18,7 @@ final class RuleEnablementWorkflowTests: XCTestCase { return RuleDocumentCommit(outcome: .saved, document: $0) }, analysisDelay: { await gate.wait() }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.query(RulesQuery(source: .custom)) let originalOrder = workflow.snapshot.rows.map(\.id) @@ -69,7 +69,7 @@ final class RuleEnablementWorkflowTests: XCTestCase { } }, analysisDelay: { await gate.wait() }, - loadBuiltin: { source in + builtinSnapshots: BuiltinRuleSnapshots(loader: { source in if source == .geolocationCN { return rulesFixture( source, @@ -79,7 +79,7 @@ final class RuleEnablementWorkflowTests: XCTestCase { ]) } return rulesFixture(source) - }) + })) await workflow.refresh() XCTAssertTrue(workflow.snapshot.isComplete) XCTAssertTrue(workflow.snapshot.sources.contains { $0.id == .geolocationCN }) @@ -109,10 +109,10 @@ final class RuleEnablementWorkflowTests: XCTestCase { return CustomRuleDocument(rules: [rule]) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, - loadBuiltin: { source in + builtinSnapshots: BuiltinRuleSnapshots(loader: { source in reads.recordSource() return rulesFixture(source) - }) + })) await workflow.refresh() await workflow.setEnabled(false, identities: [rule.identity]) XCTAssertEqual(reads.documentCount, 1) @@ -147,7 +147,7 @@ final class RuleEnablementWorkflowTests: XCTestCase { outcome: .persistenceFailed, document: try? store.loadDocument()) } }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) } await fulfillment(of: [started], timeout: 3) @@ -181,7 +181,7 @@ final class RuleEnablementWorkflowTests: XCTestCase { commits += 1 return RuleDocumentCommit(outcome: .persistenceFailed, document: try? store.loadDocument()) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version await workflow.setEnabled(false, identities: [rule.identity]) diff --git a/ShadowsocksX-NG2/Tests/RuleStatusWorkflowTests.swift b/ShadowsocksX-NG2/Tests/RuleStatusWorkflowTests.swift index 93c08b55..1d291b5c 100644 --- a/ShadowsocksX-NG2/Tests/RuleStatusWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/RuleStatusWorkflowTests.swift @@ -18,7 +18,7 @@ final class RuleStatusWorkflowTests: XCTestCase { return RuleDocumentCommit(outcome: .saved, document: nil) }, feedbackDelay: { XCTFail("An incomplete collection must not start the success timer") }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let rows = workflow.snapshot.rows await workflow.setEnabled(false, identities: [rule.identity]) @@ -46,7 +46,7 @@ final class RuleStatusWorkflowTests: XCTestCase { saveFixture(document, to: store, outcome: .saved) }, feedbackDelay: { await clock.wait() }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let fixed = try XCTUnwrap(workflow.snapshot.rows.first { $0.isFixed }?.identity) await workflow.setEnabled(true, identities: [disabled.identity, enabled.identity, fixed]) @@ -80,7 +80,7 @@ final class RuleStatusWorkflowTests: XCTestCase { await gate.wait() return saveFixture(document, to: store, outcome: .saved) }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) } await fulfillment(of: [reloadStarted], timeout: 3) @@ -121,7 +121,7 @@ final class RuleStatusWorkflowTests: XCTestCase { await clock.wait() timerReturned.fulfill() }, - loadBuiltin: { rulesFixture($0) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() await workflow.setEnabled(false, identities: [rule.identity]) await fulfillment(of: [timerStarted], timeout: 3) diff --git a/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift b/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift index 9bc18d14..ff7d88bc 100644 --- a/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift +++ b/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift @@ -6,7 +6,8 @@ import XCTest final class RulesPresentationTests: XCTestCase { func testReportIsAnExplicitSnapshotUnaffectedByQueryOrRefresh() async throws { let loader = ReportFixtureLoader() - let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { loader.load($0) }) + let workflow = RulesWorkflow( + loadCustom: { [] }, builtinSnapshots: BuiltinRuleSnapshots(loader: { loader.load($0) })) await workflow.refresh() XCTAssertTrue(workflow.openSourceReport(.gfwlist)) let opened = try XCTUnwrap(workflow.reportSource) @@ -26,7 +27,8 @@ final class RulesPresentationTests: XCTestCase { let narrow = CustomRule(action: .direct, match: try RuleMatch(domainExact: "a.example.com")) let independent = CustomRule(action: .direct, match: try RuleMatch(domainExact: "other.net")) let workflow = RulesWorkflow( - loadCustom: { [broad, narrow, independent] }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { [broad, narrow, independent] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() XCTAssertNil(workflow.selectedRelationshipRow) workflow.select([.rule(narrow.identity)]) @@ -45,7 +47,8 @@ final class RulesPresentationTests: XCTestCase { let narrow = CustomRule(action: .direct, match: try RuleMatch(domainExact: "a.example.net")) let local = CustomRule(action: .direct, match: try RuleMatch(domainExact: "device.local")) let workflow = RulesWorkflow( - loadCustom: { [broad, narrow, local] }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { [broad, narrow, local] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select([.rule(narrow.identity)]) let absorbed = try XCTUnwrap(workflow.selectedRelationshipRow) diff --git a/ShadowsocksX-NG2/Tests/RulesWorkflowCoverageTests.swift b/ShadowsocksX-NG2/Tests/RulesWorkflowCoverageTests.swift index b2862b34..c6b3aae7 100644 --- a/ShadowsocksX-NG2/Tests/RulesWorkflowCoverageTests.swift +++ b/ShadowsocksX-NG2/Tests/RulesWorkflowCoverageTests.swift @@ -10,7 +10,8 @@ final class RulesWorkflowCoverageTests: XCTestCase { let duplicate = CustomRule( action: .direct, match: try RuleMatch(domainExact: "www.example.com")) let workflow = RulesWorkflow( - loadCustom: { [broad, narrow, duplicate] }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { [broad, narrow, duplicate] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.query(RulesQuery(action: .direct, source: .custom)) let broadRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == broad.identity }) @@ -30,7 +31,8 @@ final class RulesWorkflowCoverageTests: XCTestCase { let lower = CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "8.8.8.0/25")) let upper = CustomRule(action: .direct, match: try RuleMatch(ipv4CIDR: "8.8.8.128/25")) let workflow = RulesWorkflow( - loadCustom: { [proxy, lower, upper] }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { [proxy, lower, upper] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.query(RulesQuery(action: .proxy, source: .custom)) let row = try XCTUnwrap(workflow.snapshot.rows.first) @@ -51,10 +53,10 @@ final class RulesWorkflowCoverageTests: XCTestCase { let nationalDirect = CustomRule(action: .direct, match: .domainSuffix("cn")) let workflow = RulesWorkflow( loadCustom: { [localDirect, nationalDirect] }, - loadBuiltin: { requested in + builtinSnapshots: BuiltinRuleSnapshots(loader: { requested in rulesFixture( requested, rules: requested == .gfwlist ? [simple, simpleSuffix, localProxy] : []) - }) + })) await workflow.refresh() workflow.query(RulesQuery(source: .gfwlist)) let simpleRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == simple.identity }) @@ -79,9 +81,9 @@ final class RulesWorkflowCoverageTests: XCTestCase { let proxy = ProxyRule(action: .proxy, match: try RuleMatch(domainExact: "cn")) let workflow = RulesWorkflow( loadCustom: { [direct] }, - loadBuiltin: { requested in + builtinSnapshots: BuiltinRuleSnapshots(loader: { requested in rulesFixture(requested, rules: requested == .gfwlist ? [proxy] : []) - }) + })) await workflow.refresh() workflow.query(RulesQuery(source: .custom)) let row = try XCTUnwrap(workflow.snapshot.rows.first) diff --git a/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift b/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift index 89fd6f01..7e7c78e1 100644 --- a/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift +++ b/ShadowsocksX-NG2/Tests/RulesWorkflowPublicationTests.swift @@ -5,7 +5,8 @@ import XCTest @MainActor final class RulesWorkflowPublicationTests: XCTestCase { func testNativeControlsWritingTheSameQueryDoNotRepublishThePage() async { - let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { [] }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.query(RulesQuery(source: .gfwlist)) var changes = 0 @@ -16,7 +17,8 @@ final class RulesWorkflowPublicationTests: XCTestCase { } func testNativeControlsWritingTheSameVisibleSelectionDoNotRepublishThePage() async { - let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { [] }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select([.noDotHostname]) var changes = 0 diff --git a/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift b/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift index ded17efc..c35f895f 100644 --- a/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/RulesWorkflowTests.swift @@ -27,14 +27,14 @@ final class RulesWorkflowTests: XCTestCase { outcome: .persistenceFailed, document: try? store.loadDocument()) } }, - loadBuiltin: { source in + builtinSnapshots: BuiltinRuleSnapshots(loader: { source in guard source == .gfwlist else { return rulesFixture(source) } return rulesFixture( source, rules: [ ProxyRule(action: .proxy, match: broad.match) ]) - }) + })) await workflow.refresh() let version = workflow.snapshot.version let covered = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == narrow.identity }) @@ -72,7 +72,9 @@ final class RulesWorkflowTests: XCTestCase { rules: [ProxyRule(action: .direct, match: match)]) let workflow = RulesWorkflow( loadCustom: { [custom] }, - loadBuiltin: { source in source == .geolocationCN ? snapshot : rulesFixture(source) }) + builtinSnapshots: BuiltinRuleSnapshots(loader: { source in + source == .geolocationCN ? snapshot : rulesFixture(source) + })) await workflow.refresh() workflow.query(RulesQuery(search: "EXAMPLE", action: .direct, source: .geolocationCN)) let row = try XCTUnwrap(workflow.snapshot.rows.first) @@ -85,9 +87,9 @@ final class RulesWorkflowTests: XCTestCase { func testWrongBuiltinSourceIsAnIncompleteCollectionRatherThanMislabeledRules() async { let workflow = RulesWorkflow( loadCustom: { [] }, - loadBuiltin: { _ in + builtinSnapshots: BuiltinRuleSnapshots(loader: { _ in rulesFixture(.geolocationCN) - }) + })) await workflow.refresh() XCTAssertFalse(workflow.snapshot.isComplete) XCTAssertEqual(workflow.snapshot.issues.count, 2) @@ -97,7 +99,8 @@ final class RulesWorkflowTests: XCTestCase { let direct = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com")) let proxy = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "outside.net")) let workflow = RulesWorkflow( - loadCustom: { [direct, proxy] }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { [direct, proxy] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.select([.rule(direct.identity), .rule(proxy.identity), .noDotHostname]) workflow.query(RulesQuery(search: "EXAMPLE", action: .direct, source: .custom)) @@ -114,7 +117,8 @@ final class RulesWorkflowTests: XCTestCase { CustomRule(action: .proxy, match: try RuleMatch(domainExact: "z.net")), CustomRule(action: .direct, match: try RuleMatch(domainExact: "a.net")), ] - let workflow = RulesWorkflow(loadCustom: { entries }, loadBuiltin: { rulesFixture($0) }) + let workflow = RulesWorkflow( + loadCustom: { entries }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version workflow.query(RulesQuery(source: .custom)) @@ -134,10 +138,10 @@ final class RulesWorkflowTests: XCTestCase { func testCorruptUserDocumentAndMissingBuiltinStayDistinctFromEmptySearch() async { let workflow = RulesWorkflow( loadCustom: { throw CustomRuleStoreError.corrupt(detail: "fixture") }, - loadBuiltin: { source in + builtinSnapshots: BuiltinRuleSnapshots(loader: { source in if source == .chinaIPv4 { throw RuleSnapshotError.missing } return rulesFixture(source) - }) + })) await workflow.refresh() XCTAssertFalse(workflow.snapshot.isComplete) XCTAssertEqual( @@ -154,14 +158,7 @@ final class RulesWorkflowTests: XCTestCase { let bundle = AppArtifact.bundle let workflow = RulesWorkflow( loadCustom: { [] }, - loadBuiltin: { source in - switch source { - case .geolocationCN: try BuiltinRuleCatalog.loadGeolocationCN(from: bundle) - case .chinaIPv4: try BuiltinRuleCatalog.loadChinaIPv4(from: bundle) - case .gfwlist: try BuiltinRuleCatalog.loadGFWList(from: bundle) - case .custom, .fixed: throw RuleSnapshotError.missing - } - }) + builtinSnapshots: BuiltinRuleSnapshots(bundle: bundle)) await workflow.refresh() XCTAssertTrue(workflow.snapshot.isComplete, "\(workflow.snapshot.issues)") XCTAssertEqual(workflow.snapshot.sources.count, 5) @@ -185,7 +182,8 @@ final class RulesWorkflowTests: XCTestCase { let new = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "new.net")) try store.save([old]) let workflow = RulesWorkflow( - loadCustom: { try store.load() }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { try store.load() }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() workflow.query(RulesQuery(source: .custom)) workflow.select([.rule(old.identity)]) @@ -215,7 +213,8 @@ final class RulesWorkflowTests: XCTestCase { let firstRead = XCTestExpectation(description: "first read") let barrier = RulesReadBarrier(firstRead: firstRead) let workflow = RulesWorkflow( - loadCustom: { try barrier.read() }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { try barrier.read() }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) let older = Task { await workflow.refresh() } await fulfillment(of: [firstRead], timeout: 3) defer { barrier.release.signal() } @@ -237,8 +236,12 @@ extension RulesWorkflowTests { func testCandidatePermutationKeepsVersionAndCoverageWhilePreservingInputOrder() async throws { let broad = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com")) let narrow = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "www.example.com")) - let forward = RulesWorkflow(loadCustom: { [broad, narrow] }, loadBuiltin: { rulesFixture($0) }) - let reverse = RulesWorkflow(loadCustom: { [narrow, broad] }, loadBuiltin: { rulesFixture($0) }) + let forward = RulesWorkflow( + loadCustom: { [broad, narrow] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) + let reverse = RulesWorkflow( + loadCustom: { [narrow, broad] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await forward.refresh() await reverse.refresh() XCTAssertEqual(forward.snapshot.version, reverse.snapshot.version) @@ -255,7 +258,8 @@ extension RulesWorkflowTests { let exact = CustomRule(action: .direct, match: try RuleMatch(domainExact: "example.com")) let suffix = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com")) let workflow = RulesWorkflow( - loadCustom: { [suffix, exact] }, loadBuiltin: { rulesFixture($0) }) + loadCustom: { [suffix, exact] }, + builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version let identities = workflow.snapshot.rows.map(\.identity) @@ -292,12 +296,8 @@ extension RulesWorkflowTests { func rulesFixture( _ source: RulesSource, rules: [ProxyRule] = [] ) -> RuleSnapshot { - let kind: RuleSourceKind - switch source { - case .geolocationCN: kind = .geolocationCN - case .chinaIPv4: kind = .chinaIPv4 - case .gfwlist: kind = .gfwlist - case .custom, .fixed: kind = .custom + guard let kind = source.sourceKind else { + preconditionFailure("Fixed policy has no rule snapshot") } return RuleSnapshot( metadata: RuleSnapshotMetadata(