diff --git a/CONTEXT.md b/CONTEXT.md index 6f47f2ce..f111d2bf 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. This page has no add, edit, disable, delete, or address-test actions yet; it does not change runtime source selection. +- 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 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 2c2cf820..e9061e65 100644 --- a/ShadowsocksX-NG2/App/Rules.xcstrings +++ b/ShadowsocksX-NG2/App/Rules.xcstrings @@ -363,6 +363,227 @@ } } } + }, + "IP 同时命中两种行动时,直连优先。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direct wins when an IP matches both actions." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 同时命中两种行动时,直连优先。" + } + } + } + }, + "其他命中规则": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other matching rules" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其他命中规则" + } + } + } + }, + "决定结果的规则": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rules determining the result" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "决定结果的规则" + } + } + } + }, + "固定本地策略优先。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fixed local policy takes precedence." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定本地策略优先。" + } + } + } + }, + "地址(域名、IP 或 URL)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Address (domain, IP or URL)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "地址(域名、IP 或 URL)" + } + } + } + }, + "域名同时命中两种行动时,代理优先。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy wins when a domain matches both actions." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "域名同时命中两种行动时,代理优先。" + } + } + } + }, + "显示顺序不代表路由优先级。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display order does not indicate routing priority." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示顺序不代表路由优先级。" + } + } + } + }, + "未匹配规则": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching rule" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未匹配规则" + } + } + } + }, + "未经 DNS 解析,未测试 IP 规则": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No DNS lookup was performed; IP rules were not tested." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未经 DNS 解析,未测试 IP 规则" + } + } + } + }, + "测试": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试" + } + } + } + }, + "测试中…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Testing…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试中…" + } + } + } + }, + "请输入有效的域名、IP 或带主机的 URL。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid domain, IP or URL with a host." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入有效的域名、IP 或带主机的 URL。" + } + } + } + }, + "集合不完整,无法测试地址。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The collection is incomplete; address testing is unavailable." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "集合不完整,无法测试地址。" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/RulesAddressTestView.swift b/ShadowsocksX-NG2/App/RulesAddressTestView.swift new file mode 100644 index 00000000..9054e12a --- /dev/null +++ b/ShadowsocksX-NG2/App/RulesAddressTestView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct RulesAddressTestView: View { + @ObservedObject var workflow: RulesWorkflow + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField( + RulesCopy.text("地址(域名、IP 或 URL)"), + text: Binding( + get: { workflow.snapshot.addressTest.target }, + set: { workflow.setTestTarget($0) }) + ) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .onSubmit { submit() } + Button(RulesCopy.text("测试")) { submit() } + .disabled(workflow.snapshot.isLoading || workflow.snapshot.addressTest.isTesting) + } + if workflow.snapshot.addressTest.isTesting { + ProgressView(RulesCopy.text("测试中…")).controlSize(.small) + } + if let failure = workflow.snapshot.addressTest.failure { + Label( + RulesCopy.text( + failure == .invalidTarget + ? "请输入有效的域名、IP 或带主机的 URL。" : "集合不完整,无法测试地址。"), + systemImage: "exclamationmark.triangle") + } + if let result = workflow.snapshot.addressTest.result { + resultSummary(result) + ScrollView { + VStack(alignment: .leading, spacing: 6) { + evidence(result.deciding, title: "决定结果的规则", explanation: nil) + evidence( + result.otherMatches, title: "其他命中规则", + explanation: result.explanation == .fixedLocal + ? "固定本地策略优先。" : result.explanation.label) + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 140) + } + }.padding() + } + + private func submit() { + guard !workflow.snapshot.isLoading, !workflow.snapshot.addressTest.isTesting else { return } + Task { await workflow.testAddress() } + } + + private func resultSummary(_ result: OfflineRuleMatcher.Result) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(result.outcome.label).font(.headline) + Text(verbatim: result.target).textSelection(.enabled) + } + if result.domainWithoutDNS { + Text(RulesCopy.text("未经 DNS 解析,未测试 IP 规则")).font(.caption) + } + if let explanation = result.explanation.label { + Text(RulesCopy.text(explanation)).font(.caption) + } + Text(RulesCopy.text("显示顺序不代表路由优先级。")) + .font(.caption).foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func evidence(_ rows: [RulesRow], title: String, explanation: String?) -> some View { + if !rows.isEmpty { + Text(RulesCopy.text(title)).font(.subheadline.bold()) + ForEach(rows) { row in + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: "\(row.displayContent) · \(row.matchType) · \(row.actionLabel)") + Text(verbatim: row.sourceLabels).foregroundStyle(.secondary) + if let explanation { Text(RulesCopy.text(explanation)) } + }.font(.caption).textSelection(.enabled) + } + } + } +} + +extension OfflineRuleMatcher.Outcome { + var label: String { + switch self { + case .proxy: RulesCopy.text("代理") + case .direct: RulesCopy.text("直连") + case .unmatched: RulesCopy.text("未匹配规则") + } + } +} + +extension OfflineRuleMatcher.Explanation { + var label: String? { + switch self { + case .fixedLocal: "固定本地策略优先。" + case .domainProxy: "域名同时命中两种行动时,代理优先。" + case .ipDirect: "IP 同时命中两种行动时,直连优先。" + case .singleAction, .noMatch: nil + } + } +} diff --git a/ShadowsocksX-NG2/App/RulesView.swift b/ShadowsocksX-NG2/App/RulesView.swift index 0ac29162..034503f9 100644 --- a/ShadowsocksX-NG2/App/RulesView.swift +++ b/ShadowsocksX-NG2/App/RulesView.swift @@ -1,13 +1,14 @@ import SwiftUI -/// Read-only first delivery. Every interaction goes through the workflow; no -/// filesystem, runtime control, or placeholder mutation actions live here. +/// Browsing and offline testing go through the workflow. struct RulesView: View { @ObservedObject var workflow: RulesWorkflow @Environment(\.openWindow) private var openWindow var body: some View { VStack(spacing: 0) { + RulesAddressTestView(workflow: workflow) + Divider() if workflow.snapshot.isLoading { ProgressView(RulesCopy.text("加载规则…")).padding() } diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift index 773b7c9d..dc83e06c 100644 --- a/ShadowsocksX-NG2/App/RulesWorkflow.swift +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -11,6 +11,7 @@ final class RulesWorkflow: ObservableObject { private let loadBuiltin: @Sendable (RulesSource) throws -> RuleSnapshot private var collection: RulesCollection? private var refreshGeneration = 0 + private var testGeneration = 0 init( loadCustom: @escaping @Sendable () throws -> [CustomRule] = { try CustomRuleStore().load() }, @@ -30,6 +31,7 @@ final class RulesWorkflow: ObservableObject { func refresh() async { refreshGeneration += 1 let generation = refreshGeneration + invalidateAddressTest() snapshot.isLoading = true let custom = loadCustom let builtin = loadBuiltin @@ -46,6 +48,44 @@ final class RulesWorkflow: ObservableObject { snapshot = applyingQuery(to: next) } + func setTestTarget(_ target: String) { + guard target != snapshot.addressTest.target else { return } + invalidateAddressTest() + snapshot.addressTest.target = target + } + + func testAddress() async { + invalidateAddressTest() + guard snapshot.isComplete, let collection else { + snapshot.addressTest.failure = .incompleteCollection + return + } + let generation = testGeneration + let target = snapshot.addressTest.target + snapshot.addressTest.isTesting = true + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try OfflineRuleMatcher.test(collection: collection, address: target)) + } catch { + return .failure(error as? OfflineRuleMatcher.Failure ?? .invalidTarget) + } + }.value + guard generation == testGeneration, snapshot.isComplete, + snapshot.version == collection.version + else { return } + snapshot.addressTest.isTesting = false + switch result { + case .success(let result): snapshot.addressTest.result = result + case .failure(let failure): snapshot.addressTest.failure = failure + } + } + + private func invalidateAddressTest() { + testGeneration += 1 + snapshot.addressTest = RulesAddressTest(target: snapshot.addressTest.target) + } + func query(_ query: RulesQuery) { guard query != snapshot.query else { return } var next = snapshot diff --git a/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift b/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift new file mode 100644 index 00000000..b14becd2 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/OfflineRuleMatcher.swift @@ -0,0 +1,112 @@ +import Foundation + +/// A full saved collection is the only context: no runtime, default action, or network dependencies. +enum OfflineRuleMatcher { + enum Outcome: Equatable, Sendable { case proxy, direct, unmatched } + enum Explanation: Equatable, Sendable { + case fixedLocal, domainProxy, ipDirect, singleAction, noMatch + } + enum Failure: Error, Equatable, Sendable { case invalidTarget, incompleteCollection } + + struct Result: Equatable, Sendable { + let version: String + let target: String + let outcome: Outcome + let domainWithoutDNS: Bool + let deciding: [RulesRow] + let otherMatches: [RulesRow] + let explanation: Explanation + } + + static func test(collection: RulesCollection, address: String) throws -> Result { + guard collection.issues.isEmpty else { throw Failure.incompleteCollection } + let target = try OfflineRuleTarget(address) + let matches = collection.rows.filter { row in + guard let identity = row.identity else { return target.isSimpleHostname } + return RuleCoverage.domainCovers(identity.match, target.match) + || RuleCoverage.ipCovers(identity.match, target.match) + } + let fixed = matches.filter(\.isFixed) + let action: RuleAction? + let explanation: Explanation + if !fixed.isEmpty { + action = .direct + explanation = .fixedLocal + } else if Set(matches.map(\.action)).count > 1 { + action = RuleCoverage.winningAction(for: target.match) + explanation = target.isDomain ? .domainProxy : .ipDirect + } else { + action = matches.first?.action + explanation = action == nil ? .noMatch : .singleAction + } + let deciding = matches.filter { + fixed.isEmpty ? $0.action == action : $0.isFixed + } + let decidingIDs = Set(deciding.map(\.id)) + return Result( + version: collection.version, target: target.display, + outcome: action.map { $0 == .proxy ? .proxy : .direct } ?? .unmatched, + domainWithoutDNS: target.isDomain, deciding: deciding, + otherMatches: matches.filter { !decidingIDs.contains($0.id) }, explanation: explanation) + } +} + +private struct OfflineRuleTarget { + let match: RuleMatch + let display: String + var isDomain: Bool { + if case .domainExact = match { return true } + return false + } + var isSimpleHostname: Bool { isDomain && !display.contains(".") } + + init(_ address: String) throws { + let raw = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { throw OfflineRuleMatcher.Failure.invalidTarget } + var host = raw + if raw.contains("://") { + guard let url = URL(string: raw, encodingInvalidCharacters: false), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme != nil, let value = components.host, !value.isEmpty, + components.percentEncodedHost?.contains("%") == false, + components.port.map({ (0...65535).contains($0) }) ?? true + else { throw OfflineRuleMatcher.Failure.invalidTarget } + host = value + if host.hasPrefix("["), host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + } + if let addressMatch = try? RuleMatch(ipv4CIDR: host), !host.contains("/") { + match = addressMatch + display = String(addressMatch.browsingContent.split(separator: "/")[0]) + return + } + if let addressMatch = try? RuleMatch(ipv6CIDR: host), !host.contains("/") { + match = addressMatch + display = String(addressMatch.browsingContent.split(separator: "/")[0]) + return + } + host = host.lowercased() + if host.hasSuffix(".") { host.removeLast() } + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + guard !host.isEmpty, host.utf8.count <= 253, + !host.allSatisfy({ $0.isNumber || $0 == "." }), + labels.allSatisfy({ label in + !label.isEmpty && label.utf8.count <= 63 + && label.first != "-" && label.last != "-" + && label.utf8.allSatisfy { byte in + (97...122).contains(byte) || (48...57).contains(byte) || byte == 45 + } + }) + else { throw OfflineRuleMatcher.Failure.invalidTarget } + match = .domainExact(host) + display = host + } +} + +struct RulesAddressTest: Equatable, Sendable { + var target = "" + var isTesting = false + var result: OfflineRuleMatcher.Result? + var failure: OfflineRuleMatcher.Failure? +} diff --git a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift index 7f4cc2d4..686c7e49 100644 --- a/ShadowsocksX-NG2/Domain/RulesBrowsing.swift +++ b/ShadowsocksX-NG2/Domain/RulesBrowsing.swift @@ -49,5 +49,6 @@ struct RulesPageSnapshot: Equatable, Sendable { var rows: [RulesRow] = [] var selection: Set = [] var query = RulesQuery() + var addressTest = RulesAddressTest() var isComplete: Bool { !isLoading && issues.isEmpty && !version.isEmpty } } diff --git a/ShadowsocksX-NG2/Tests/OfflineRuleMatcherTests.swift b/ShadowsocksX-NG2/Tests/OfflineRuleMatcherTests.swift new file mode 100644 index 00000000..b5deef72 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/OfflineRuleMatcherTests.swift @@ -0,0 +1,146 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +final class OfflineRuleMatcherTests: XCTestCase { + func testURLAndLiteralIPNormalizationSelectOnlyTheHostBranch() throws { + let rules = [ + CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com")), + CustomRule(action: .proxy, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24")), + CustomRule(action: .direct, match: try RuleMatch(ipv6CIDR: "2001:db8::/32")), + ] + let collection = RulesCollection.load(custom: { rules }, builtin: { rulesFixture($0) }) + for input in [" A.Example.COM. \n", "https://A.Example.COM.:8443/path?q=203.0.113.8#fragment"] { + let result = try OfflineRuleMatcher.test(collection: collection, address: input) + XCTAssertEqual(result.target, "a.example.com") + XCTAssertEqual(result.outcome, .proxy) + XCTAssertTrue(result.domainWithoutDNS) + } + for input in ["203.0.113.8", "https://203.0.113.8:443/path"] { + let result = try OfflineRuleMatcher.test(collection: collection, address: input) + XCTAssertEqual(result.target, "203.0.113.8") + XCTAssertEqual(result.outcome, .proxy) + XCTAssertFalse(result.domainWithoutDNS) + } + let ipv6 = try OfflineRuleMatcher.test( + collection: collection, address: "https://[2001:DB8::8]:8443/path") + XCTAssertEqual(ipv6.target, "2001:db8::8") + XCTAssertEqual(ipv6.outcome, .direct) + XCTAssertFalse(ipv6.domainWithoutDNS) + } + func testDomainOverlapAndSuffixBoundariesKeepAllCommonEvidence() throws { + let suffix = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com")) + let exact = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "a.example.com")) + let secondProxy = CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com")) + let rules = [suffix, exact, secondProxy] + for entries in [rules, Array(rules.reversed())] { + let collection = RulesCollection.load(custom: { entries }, builtin: { rulesFixture($0) }) + let result = try OfflineRuleMatcher.test(collection: collection, address: "a.example.com") + XCTAssertEqual(result.outcome, .proxy) + XCTAssertEqual(result.explanation, .domainProxy) + XCTAssertEqual( + Set(result.deciding.compactMap(\.identity)), [exact.identity, secondProxy.identity]) + XCTAssertEqual(result.otherMatches.map(\.identity), [suffix.identity]) + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: collection, address: "notexample.com").outcome, + .unmatched) + } + let collection = RulesCollection.load( + custom: { [suffix, exact] }, builtin: { rulesFixture($0) }) + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: collection, address: "example.com").outcome, .direct) + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: collection, address: "b.example.com").outcome, .direct + ) + let exactOnly = RulesCollection.load(custom: { [exact] }, builtin: { rulesFixture($0) }) + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: exactOnly, address: "sub.a.example.com").outcome, + .unmatched) + } + + func testIPOverlapAndMappedAddressesPreferDirectWithoutLosingOtherRanges() throws { + for reverseActions in [false, true] { + let broad = CustomRule( + action: reverseActions ? .direct : .proxy, + match: try RuleMatch(ipv4CIDR: "203.0.113.0/24")) + let narrow = CustomRule( + action: reverseActions ? .proxy : .direct, + match: try RuleMatch(ipv6CIDR: "::ffff:203.0.113.8/128")) + let collection = RulesCollection.load( + custom: { [broad, narrow] }, builtin: { rulesFixture($0) }) + for address in ["203.0.113.8", "::ffff:203.0.113.8"] { + let result = try OfflineRuleMatcher.test(collection: collection, address: address) + XCTAssertEqual(result.outcome, .direct) + XCTAssertEqual(result.explanation, .ipDirect) + XCTAssertEqual( + result.deciding.map(\.identity), [reverseActions ? broad.identity : narrow.identity]) + XCTAssertEqual( + result.otherMatches.map(\.identity), [reverseActions ? narrow.identity : broad.identity]) + } + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: collection, address: "203.0.113.9").outcome, + reverseActions ? .direct : .proxy) + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: collection, address: "198.51.100.8").outcome, + .unmatched) + XCTAssertEqual( + try OfflineRuleMatcher.test(collection: collection, address: "unresolved.example").outcome, + .unmatched) + } + } + + func testFixedPolicyWinsAndSourceMembershipsRemainComplete() throws { + let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "gfw") + let proxy = ProxyRule(action: .proxy, match: .domainSuffix("local"), source: source) + let collection = RulesCollection.load( + custom: { [] }, + builtin: { + rulesFixture($0, rules: $0 == .gfwlist ? [proxy] : []) + }) + for address in [ + "printer", "localhost", "a.localhost", "host.local", "127.0.0.1", "192.168.1.2", + "::1", "::ffff:192.168.1.2", "fe80::1", + ] { + let result = try OfflineRuleMatcher.test(collection: collection, address: address) + XCTAssertEqual(result.outcome, .direct, address) + XCTAssertEqual(result.explanation, .fixedLocal, address) + XCTAssertTrue(result.deciding.allSatisfy(\.isFixed)) + } + let local = try OfflineRuleMatcher.test(collection: collection, address: "host.local") + XCTAssertEqual(local.otherMatches.map(\.identity), [proxy.identity]) + let match = try RuleMatch(domainExact: "example.com") + let custom = CustomRule(action: .proxy, match: match) + let duplicate = ProxyRule(action: .proxy, match: match, source: source) + let merged = RulesCollection.load( + custom: { [custom] }, + builtin: { + rulesFixture($0, absorbed: $0 == .gfwlist ? [duplicate] : []) + }) + let result = try OfflineRuleMatcher.test(collection: merged, address: "example.com") + XCTAssertEqual(result.deciding.count, 1) + XCTAssertEqual(result.deciding[0].sources, [.custom, .gfwlist]) + XCTAssertEqual(result.deciding[0].customIDs, [custom.id]) + } + + func testInvalidInputsAreOperationErrorsRatherThanUnmatched() throws { + let collection = RulesCollection.load(custom: { [] }, builtin: { rulesFixture($0) }) + for input in [ + "", " ", "example..com", "example.com..", "-example.com", "example-.com", + "https:///path", "https://", "file:///tmp/test", "203.0.113.999", "203.0.113.8/32", + "2001:db8::xyz", "example.com:443", "a example.com", "*.example.com", + "https://example.com:99999", + "https://ex%61mple.com", "例子.com", "https://例子.com", + ] { + XCTAssertThrowsError( + try OfflineRuleMatcher.test(collection: collection, address: input), input + ) { + XCTAssertEqual($0 as? OfflineRuleMatcher.Failure, .invalidTarget, input) + } + } + let valid = try OfflineRuleMatcher.test( + collection: collection, address: "XN--BCHER-KVA.example.") + XCTAssertEqual(valid.target, "xn--bcher-kva.example") + XCTAssertEqual(valid.outcome, .unmatched) + } + +} diff --git a/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift b/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift new file mode 100644 index 00000000..385ba9e7 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/OfflineRulesWorkflowTests.swift @@ -0,0 +1,129 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +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) }) + await workflow.refresh() + workflow.query(RulesQuery(search: "hidden", action: .direct, source: .chinaIPv4)) + XCTAssertTrue(workflow.snapshot.rows.isEmpty) + workflow.setTestTarget("a.example.com") + await workflow.testAddress() + let result = try XCTUnwrap(workflow.snapshot.addressTest.result) + XCTAssertEqual(result.outcome, .proxy) + XCTAssertEqual(result.version, workflow.snapshot.version) + XCTAssertEqual(result.deciding.map(\.identity), [rule.identity]) + XCTAssertTrue(result.domainWithoutDNS) + } + func testInvalidAndIncompleteCollectionsExposeOperationErrors() async throws { + let workflow = RulesWorkflow(loadCustom: { [] }, loadBuiltin: { rulesFixture($0) }) + workflow.setTestTarget("example.com") + await workflow.testAddress() + XCTAssertEqual(workflow.snapshot.addressTest.failure, .incompleteCollection) + await workflow.refresh() + workflow.setTestTarget("https://") + await workflow.testAddress() + XCTAssertEqual(workflow.snapshot.addressTest.failure, .invalidTarget) + XCTAssertNil(workflow.snapshot.addressTest.result) + let incomplete = RulesWorkflow( + loadCustom: { [] }, loadBuiltin: { _ in throw RuleSnapshotError.missing }) + await incomplete.refresh() + incomplete.setTestTarget("localhost") + await incomplete.testAddress() + XCTAssertEqual(incomplete.snapshot.addressTest.failure, .incompleteCollection) + XCTAssertNil(incomplete.snapshot.addressTest.result) + } + + func testPublicRefreshClearsOldResultAndUsesNewSavedVersion() 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 proxy = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "example.com")) + try store.save([proxy]) + let workflow = RulesWorkflow( + loadCustom: { try store.load() }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.setTestTarget("example.com") + await workflow.testAddress() + let version = workflow.snapshot.addressTest.result?.version + try store.save([CustomRule(action: .direct, match: proxy.match)]) + await workflow.refresh() + XCTAssertNil(workflow.snapshot.addressTest.result) + XCTAssertFalse(workflow.snapshot.addressTest.isTesting) + XCTAssertNotEqual(workflow.snapshot.version, version) + XCTAssertEqual(workflow.snapshot.addressTest.target, "example.com") + await workflow.testAddress() + XCTAssertEqual(workflow.snapshot.addressTest.result?.outcome, .direct) + try Data("broken".utf8).write(to: store.fileURL) + await workflow.refresh() + await workflow.testAddress() + XCTAssertEqual(workflow.snapshot.addressTest.failure, .incompleteCollection) + XCTAssertNil(workflow.snapshot.addressTest.result) + } + + func testClearingTargetOrStartingRefreshPreventsLateTestPublication() async throws { + let rule = CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com")) + let workflow = RulesWorkflow(loadCustom: { [rule] }, loadBuiltin: { rulesFixture($0) }) + await workflow.refresh() + workflow.setTestTarget("example.com") + await workflow.testAddress() + workflow.setTestTarget("") + XCTAssertNil(workflow.snapshot.addressTest.result) + XCTAssertNil(workflow.snapshot.addressTest.failure) + + // Queue a new public command as soon as the old test starts. The old + // detached task must not publish after that command invalidates its intent. + for refresh in [false, true] { + workflow.setTestTarget("example.com") + var invalidation: Task? + let observation = workflow.$snapshot.sink { page in + guard page.addressTest.isTesting, invalidation == nil else { return } + invalidation = Task { @MainActor in + if refresh { await workflow.refresh() } else { workflow.setTestTarget("") } + } + } + await workflow.testAddress() + await invalidation?.value + observation.cancel() + XCTAssertNil(workflow.snapshot.addressTest.result) + XCTAssertFalse(workflow.snapshot.addressTest.isTesting) + } + } + + func testExternalSettingsChangesNeverSupplyAFallbackOrAlterThreeStateResults() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let settings = ProxySettingsFileStore( + fileURL: directory.appendingPathComponent("settings.json")) + let rules = [ + 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) }) + await workflow.refresh() + for mode in [ProxyModeKind.rule, .global, .direct] { + for defaultAction in RuleDefaultAction.allCases { + for enabled in [false, true] { + try settings.save( + ProxySettings( + preferredMode: mode, ruleDefaultAction: defaultAction, + agentEnabled: enabled, systemProxyEnabled: enabled)) + workflow.query(RulesQuery(search: "hidden", source: .fixed, sort: .descending)) + for (target, outcome) in [ + ("proxy.example", OfflineRuleMatcher.Outcome.proxy), + ("direct.example", .direct), ("unresolved.example", .unmatched), + ("198.51.100.8", .unmatched), + ] { + workflow.setTestTarget(target) + await workflow.testAddress() + XCTAssertEqual(workflow.snapshot.addressTest.result?.outcome, outcome) + } + } + } + } + } + +}