feat(rules): add independent offline address tests

Implement #77 using the complete saved versioned rule collection. Normalize domain, IP, and URL hosts; preserve three outcomes and all deciding identities, source memberships, and other target-specific matches without DNS or runtime dependencies.

Invalidate results when the target changes or refresh starts, and reject late asynchronous publication. Add the native address test panel and approved Chinese/English copy.

Validation: 760 hostless tests and 2 fixed-sslocal semantics smoke tests passed; ad-hoc Debug build passed. Changed-file format/lint clean; existing repository warnings remain. Standards and Spec reviews found no issues. Visual acceptance remains manual.
This commit is contained in:
Qiu Yuzhou
2026-10-02 20:59:26 +08:00
parent cb5a535297
commit beaa92781a
9 changed files with 755 additions and 3 deletions
+1 -1
View File
@@ -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.
+221
View File
@@ -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"
@@ -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
}
}
}
+3 -2
View File
@@ -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()
}
+40
View File
@@ -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<OfflineRuleMatcher.Result, OfflineRuleMatcher.Failure>.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
@@ -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?
}
@@ -49,5 +49,6 @@ struct RulesPageSnapshot: Equatable, Sendable {
var rows: [RulesRow] = []
var selection: Set<RulesRow.SelectionID> = []
var query = RulesQuery()
var addressTest = RulesAddressTest()
var isComplete: Bool { !isLoading && issues.isEmpty && !version.isEmpty }
}
@@ -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)
}
}
@@ -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<Void, Never>?
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)
}
}
}
}
}
}