fix(rules): make validation and ACL order independent (#75)

Normalize source-independent match/action identities while preserving custom UUIDs. Analyze complete valid candidates for same-action coverage and full or partial opposite-action shadowing without dropping saved intent. Keep duplicate and fixed-local conflicts blocking, including mapped IPv6.

Canonicalize ACL entries without changing runtime source selection or proxy-default skeleton. Verify persistence and no-restart reordering through the Controller, collection permutations, and pinned sslocal loopback comparisons.

Validation: 728 hostless tests and 2 pinned-binary smoke tests passed with ad-hoc arm64 signing; format, lint (3 existing warnings, 0 errors), and diff checks passed. No real system-proxy writes or automatic visual checks.
This commit is contained in:
Qiu Yuzhou
2026-10-02 16:34:52 +08:00
parent e3ca886b9f
commit 280a3fe6fe
14 changed files with 527 additions and 162 deletions
@@ -92,8 +92,7 @@ extension ProxyRuntimeController {
}
/// 保存前校验(issue #66 AC2):固定本地冲突与重复整批拒绝并返回可解释原因。
/// 单模式遮蔽的规则允许保存(在另一模式可生效),编译时按当前模式过滤并
/// 通过 `ruleModeValidation` 返回原因;不把无效规则标为生效。
/// 同行动覆盖和相反行动遮蔽不阻止保存;编译时按当前骨架解释完整集合。
func validateCustomRulesForPersistence(_ rules: [CustomRule]) -> (
acceptedRules: [CustomRule], rejected: [RejectedCustomRule]
) {
@@ -104,13 +104,13 @@ extension ProxyRuntimeController {
/// 规则模式候选按默认动作取对应来源(issue #65/#66):「未匹配时代理」用
/// 中国直连候选;「未匹配时直连」用 GFWList 代理候选。两种默认动作不把
/// 全部内置来源无条件并集。自定义规则与对应内置来源合并(issue #66);
/// 被遮蔽的自定义规则不进入 ACL,并经 `ruleModeValidation` 返回原因。
/// 保留可表达自定义条目,并经 `ruleModeValidation` 返回覆盖关系。
/// 全局和直连模式不加载自定义规则(它们不调用本方法)。
func ruleModeCandidateRules() throws -> [ProxyRule] {
try ruleModeValidation().accepted
}
/// 规则模式编译校验结果:可生效候选 + 被拒绝的自定义规则及原因(issue #66 AC2)。
/// 规则模式的完整有效候选、硬拒绝项与当前 ACL 骨架下的覆盖说明。
func ruleModeValidation() throws -> CustomRuleValidationResult {
let custom = try customRuleStore.load()
switch settings.ruleDefaultAction {
@@ -119,13 +119,15 @@ extension ProxyRuntimeController {
let validated = CustomRuleValidator.validate(
custom: custom, builtIn: builtIn, defaultAction: .proxyWhenUnmatched)
return CustomRuleValidationResult(
accepted: builtIn + validated.accepted, rejected: validated.rejected)
accepted: builtIn + validated.accepted, rejected: validated.rejected,
relationships: validated.relationships)
case .directWhenUnmatched:
let builtIn = try gfwlistRules()
let validated = CustomRuleValidator.validate(
custom: custom, builtIn: builtIn, defaultAction: .directWhenUnmatched)
return CustomRuleValidationResult(
accepted: builtIn + validated.accepted, rejected: validated.rejected)
accepted: builtIn + validated.accepted, rejected: validated.rejected,
relationships: validated.relationships)
}
}
+4 -2
View File
@@ -28,12 +28,14 @@ struct CustomRule: Codable, Equatable, Hashable, Sendable, Identifiable {
/// 折叠为来源无关的运行时规则,参与 ACL 合并。
var proxyRule: ProxyRule {
ProxyRule(action: action, match: match, source: source)
ProxyRule(action: action, match: identity.match, source: source)
}
var identity: RuleIdentity { RuleIdentity(action: action, match: match) }
/// 稳定的内容身份(不含 UUID),用于去重与诊断摘要。
var contentToken: String {
"\(action.rawValue)|\(String(describing: match))"
identity.contentToken
}
}
+75 -116
View File
@@ -16,18 +16,11 @@ struct ParsedCIDR: Equatable, Sendable {
// MARK: - 拒绝原因
/// 自定义规则被拒绝的原因(issue #66 AC2):与固定本地范围冲突,或被原版
/// sslocal 域名代理 / IP 直连优先级遮蔽。每条附可解释说明。
/// Blocking custom-rule validation errors. Shadowing is a relationship, not an error.
enum CustomRuleRejection: Equatable, Sendable {
/// 代理动作覆盖固定本地范围(回环/私有/链路本地/localhost/*.local/无点主机名)。
/// 应用固定的本地绕过规则始终优先于用户代理规则;冲突规则不会生效。
case conflictsWithFixedLocalScope
/// 域名直连规则被更宽(或同覆盖)的域名代理规则遮蔽:sslocal 域名匹配
/// 时 proxy_list 优先于 bypass_list。
case shadowedByDomainProxy
/// IP 代理规则被更宽(或同覆盖)的 IP 直连规则遮蔽:sslocal IP 匹配时
/// bypass_list 优先于 proxy_list。
case shadowedByIPDirect
/// 同动作+同匹配条件重复。
case duplicate
@@ -35,10 +28,6 @@ enum CustomRuleRejection: Equatable, Sendable {
switch self {
case .conflictsWithFixedLocalScope:
return "与固定本地范围冲突"
case .shadowedByDomainProxy:
return "被域名代理优先级遮蔽"
case .shadowedByIPDirect:
return "被 IP 直连优先级遮蔽"
case .duplicate:
return "重复规则"
}
@@ -53,10 +42,11 @@ struct RejectedCustomRule: Equatable, Sendable {
let explanation: String
}
/// 校验结果:可生效规则(折叠为运行时规则)与被拒绝项。
/// Valid candidates, blocking errors, and nonblocking coverage explanations.
struct CustomRuleValidationResult: Equatable, Sendable {
let accepted: [ProxyRule]
let rejected: [RejectedCustomRule]
var relationships: [RuleRelationship] = []
}
// MARK: - 覆盖判定
@@ -86,8 +76,7 @@ enum RuleCoverage {
/// 域名目标集合是否相交(用于固定本地冲突)。
static func domainIntersects(_ lhs: RuleMatch, _ rhs: RuleMatch) -> Bool {
// 任一覆盖另一即相交;后缀 vs 后缀若互不覆盖也可能相交(如 a.b 与 b.c),
// 但固定本地规则是 `local`/`localhost`/无点,与多标签后缀的相交只能经覆盖。
// 任一覆盖另一即相交;后缀的点分隔边界确保互不覆盖的两个后缀不相交。
domainCovers(lhs, rhs) || domainCovers(rhs, lhs)
}
@@ -122,10 +111,9 @@ enum RuleCoverage {
// MARK: IP
/// `covering` 的 IP 目标集合是否包含 `covered` 的 IP 目标集合(同地址族)。
/// `covering` 的 IP 目标集合是否包含 `covered` 的 IP 目标集合(含 mapped 跨家族匹配)。
static func ipCovers(_ covering: RuleMatch, _ covered: RuleMatch) -> Bool {
guard let outer = parseCIDR(covering), let inner = parseCIDR(covered),
outer.family == inner.family
guard let outer = matchingCIDR(covering), let inner = matchingCIDR(covered)
else { return false }
return outer.prefixLength <= inner.prefixLength
&& networkContains(outer: outer, inner: inner)
@@ -133,7 +121,7 @@ enum RuleCoverage {
/// IP 目标集合是否相交。
static func ipIntersects(_ lhs: RuleMatch, _ rhs: RuleMatch) -> Bool {
guard let left = parseCIDR(lhs), let right = parseCIDR(rhs), left.family == right.family
guard let left = matchingCIDR(lhs), let right = matchingCIDR(rhs)
else { return false }
return networkContains(outer: left, inner: right) || networkContains(outer: right, inner: left)
}
@@ -148,6 +136,52 @@ enum RuleCoverage {
return false
}
static func winningAction(for match: RuleMatch) -> RuleAction {
switch match {
case .domainExact, .domainSuffix: .proxy
case .ipv4CIDR, .ipv6CIDR: .direct
}
}
static func intersects(_ lhs: RuleMatch, _ rhs: RuleMatch) -> Bool {
domainIntersects(lhs, rhs) || ipIntersects(lhs, rhs)
}
static func fullyCovered(_ target: RuleMatch, by matches: [RuleMatch]) -> Bool {
if matches.contains(where: { domainCovers($0, target) }) { return true }
guard let range = matchingCIDR(target) else { return false }
return fullyCovered(range, by: matches.compactMap { matchingCIDR($0) })
}
/// IPv4 and its mapped IPv6 image match the same addresses in sslocal v1.25.0.
/// Identity keeps the original family; coverage uses the common IPv6 space.
private static func matchingCIDR(_ match: RuleMatch) -> ParsedCIDR? {
guard let range = parseCIDR(match) else { return nil }
guard range.family == .ipv4 else { return range }
return ParsedCIDR(
family: .ipv6, bytes: Array(repeating: 0, count: 10) + [255, 255] + range.bytes,
prefixLength: 96 + range.prefixLength)
}
/// Several disjoint narrower CIDRs can together cover a whole range.
private static func fullyCovered(_ target: ParsedCIDR, by ranges: [ParsedCIDR]) -> Bool {
let overlapping = ranges.filter {
networkContains(outer: $0, inner: target) || networkContains(outer: target, inner: $0)
}
if overlapping.contains(where: {
$0.prefixLength <= target.prefixLength && networkContains(outer: $0, inner: target)
}) {
return true
}
guard !overlapping.isEmpty, target.prefixLength < 128 else { return false }
let nextPrefix = target.prefixLength + 1
var upperBytes = target.bytes
upperBytes[target.prefixLength / 8] |= UInt8(1) << (7 - target.prefixLength % 8)
let lower = ParsedCIDR(family: .ipv6, bytes: target.bytes, prefixLength: nextPrefix)
let upper = ParsedCIDR(family: .ipv6, bytes: upperBytes, prefixLength: nextPrefix)
return fullyCovered(lower, by: overlapping) && fullyCovered(upper, by: overlapping)
}
// MARK: 解析
static func parseCIDR(_ match: RuleMatch) -> ParsedCIDR? {
@@ -241,74 +275,49 @@ enum RuleCoverage {
// MARK: - 校验器
/// 自定义规则校验(issue #66 AC2):拒绝与固定本地范围冲突、重复,以及
/// 被原版 sslocal 域名代理 / IP 直连优先级遮蔽的规则,并返回可解释原因。
///
/// 遮蔽判定相对「将进入同一 ACL 的规则集合」(内置来源 + 其他自定义规则)。
/// 固定本地冲突是模式无关的硬拒绝;遮蔽与 ACL 骨架有关,由调用方按默认动作
/// 提供对应上下文。不把无效规则标为生效。
/// Reject only duplicate custom identities and fixed-local conflicts. Analyze
/// coverage against the complete valid set using the runtime's expressed actions.
/// Accepted candidates retain saved intent; relationships never remove entries.
enum CustomRuleValidator {
/// 校验一组自定义规则(issue #66)。
///
/// - Parameters:
/// - custom: 待校验的自定义规则(顺序保留)。
/// - builtIn: 将与自定义规则合并进同一 ACL 的内置规则。
/// - defaultAction: 规则模式子选项,决定 ACL 骨架与遮蔽语义。
/// 「未匹配时代理」骨架不写 proxy_list(默认已是代理),代理动作规则
/// 不会被域名代理优先级遮蔽(域名代理表为空),IP 代理也不参与
/// bypass 优先判定。
static func validate(
custom: [CustomRule],
builtIn: [ProxyRule] = [],
defaultAction: RuleDefaultAction
) -> CustomRuleValidationResult {
let writesProxySide = defaultAction == .directWhenUnmatched
var accepted: [ProxyRule] = []
var rejected: [RejectedCustomRule] = []
var seenTokens = Set<String>()
// 遮蔽判定集合:先通过固定本地/重复检查的规则才可能进入 ACL,
// 被拒绝的规则不得充当遮蔽方。
var shadowContext = builtIn
for rule in custom {
let token = rule.contentToken
if !seenTokens.insert(token).inserted {
let counts = Dictionary(grouping: custom, by: \.contentToken).mapValues(\.count)
for rule in custom.sorted(by: {
($0.contentToken, $0.id.uuidString) < ($1.contentToken, $1.id.uuidString)
}) {
if counts[rule.contentToken, default: 0] > 1 {
rejected.append(
RejectedCustomRule(
rule: rule,
reason: .duplicate,
explanation: "同动作同匹配条件的规则已存在"))
continue
}
if let rejection = fixedLocalRejection(for: rule) {
rule: rule, reason: .duplicate, explanation: "同动作同匹配条件的规则已存在"))
} else if let rejection = fixedLocalRejection(for: rule) {
rejected.append(rejection)
continue
} else {
accepted.append(rule.proxyRule)
}
if let rejection = shadowRejection(
for: rule, in: shadowContext, writesProxySide: writesProxySide)
{
rejected.append(rejection)
continue
}
accepted.append(rule.proxyRule)
shadowContext.append(rule.proxyRule)
}
return CustomRuleValidationResult(accepted: accepted, rejected: rejected)
let candidates = builtIn + accepted
let expressed =
defaultAction == .proxyWhenUnmatched
? candidates.filter { $0.action == .direct } : candidates
let analysis = RuleAnalysis(rules: expressed, subjects: accepted)
return CustomRuleValidationResult(
accepted: accepted, rejected: rejected, relationships: analysis.relationships)
}
/// 单条规则的固定本地冲突检查(保存与编译共用)。
static func fixedLocalRejection(for rule: CustomRule) -> RejectedCustomRule? {
guard rule.action == .proxy else { return nil }
let conflicts: Bool
switch rule.match {
switch rule.identity.match {
case .domainExact, .domainSuffix:
conflicts = RuleCoverage.domainConflictsWithFixedLocal(rule.match)
conflicts = RuleCoverage.domainConflictsWithFixedLocal(rule.identity.match)
case .ipv4CIDR, .ipv6CIDR:
conflicts = RuleCoverage.ipConflictsWithFixedLocal(rule.match)
conflicts = RuleCoverage.ipConflictsWithFixedLocal(rule.identity.match)
}
guard conflicts else { return nil }
return RejectedCustomRule(
@@ -317,54 +326,4 @@ enum CustomRuleValidator {
explanation: "应用固定的本地绕过规则优先;这些本地目标不会经代理")
}
/// ACL 优先级遮蔽检查:
/// - 域名直连 vs 域名代理(proxy_list 优先)。
/// - IP 代理 vs IP 直连(bypass_list 优先)。
/// 仅在对应侧写入 ACL 时才可能被遮蔽。
private static func shadowRejection(
for rule: CustomRule,
in merged: [ProxyRule],
writesProxySide: Bool
) -> RejectedCustomRule? {
switch rule.match {
case .domainExact, .domainSuffix:
// 域名直连被域名代理遮蔽;仅当代理侧写入 ACL 时才存在代理优先表。
guard rule.action == .direct, writesProxySide else { return nil }
for other in merged where other.action == .proxy {
if RuleCoverage.domainCovers(other.match, rule.match) {
return RejectedCustomRule(
rule: rule,
reason: .shadowedByDomainProxy,
explanation: "域名代理规则 \(describe(other.match)) 优先于直连规则,本规则不会生效")
}
}
return nil
case .ipv4CIDR, .ipv6CIDR:
// IP 代理被 IP 直连遮蔽;直连侧在两种骨架下都写入。
guard rule.action == .proxy else { return nil }
// 「未匹配时代理」不写 proxy_list,IP 代理规则不进入 ACL(默认已代理),
// 不构成「被遮蔽」;只有写入 proxy 侧时才检查 IP 直连优先。
guard writesProxySide else { return nil }
for other in merged where other.action == .direct {
if RuleCoverage.ipCovers(other.match, rule.match) {
return RejectedCustomRule(
rule: rule,
reason: .shadowedByIPDirect,
explanation: "IP 直连规则 \(describe(other.match)) 优先于代理规则,本规则不会生效")
}
}
return nil
}
}
private static func describe(_ match: RuleMatch) -> String {
switch match {
case .domainExact(let domain):
return "|\(domain)"
case .domainSuffix(let domain):
return "||\(domain)"
case .ipv4CIDR(let cidr), .ipv6CIDR(let cidr):
return cidr
}
}
}
+8 -3
View File
@@ -113,12 +113,17 @@ struct ProxyACLDocument: Codable, Equatable, Sendable {
defaultAction: RuleDefaultAction,
rules: [ProxyRule]
) -> ProxyACLDocument {
// Select expressed actions before any coverage analysis. Keep fixed policy
// first; equivalent source entries and array order never change ACL bytes.
let directLines = Array(Set(rules.filter { $0.action == .direct }.map(\.aclLine)))
.filter { !FixedLocalProxyRanges.aclBypassRules.contains($0) }.sorted()
let proxyLines = Array(Set(rules.filter { $0.action == .proxy }.map(\.aclLine))).sorted()
switch defaultAction {
case .proxyWhenUnmatched:
// proxy_all + 直连候选进 bypass_list(中国域名 / IPv4 CIDR)。
let bypass =
FixedLocalProxyRanges.aclBypassRules
+ rules.filter { $0.action == .direct }.map { $0.aclLine }
+ directLines
return makeDocument(
at: fileURL, header: "[proxy_all]", summary: "rule-proxy-default",
bypassRules: bypass, proxyRules: [])
@@ -127,8 +132,8 @@ struct ProxyACLDocument: Codable, Equatable, Sendable {
// 进 bypass_list。两种默认动作不无条件并集中国来源。
let bypass =
FixedLocalProxyRanges.aclBypassRules
+ rules.filter { $0.action == .direct }.map { $0.aclLine }
let proxy = rules.filter { $0.action == .proxy }.map { $0.aclLine }
+ directLines
let proxy = proxyLines
return makeDocument(
at: fileURL, header: "[bypass_all]", summary: "rule-direct-default",
bypassRules: bypass, proxyRules: proxy)
+32 -7
View File
@@ -50,13 +50,13 @@ enum RuleMatch: Equatable, Hashable, Codable, Sendable {
let value = try container.decode(String.self, forKey: .value)
switch try container.decode(Kind.self, forKey: .kind) {
case .domainExact:
self = .domainExact(value)
try self.init(domainExact: value)
case .domainSuffix:
self = .domainSuffix(value)
try self.init(nationalDomainSuffix: value)
case .ipv4CIDR:
self = .ipv4CIDR(value)
try self.init(ipv4CIDR: value)
case .ipv6CIDR:
self = .ipv6CIDR(value)
try self.init(ipv6CIDR: value)
}
}
@@ -145,7 +145,7 @@ enum RuleMatch: Equatable, Hashable, Codable, Sendable {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw RuleMatchError.invalidCIDR(raw) }
let parts = trimmed.split(separator: "/", maxSplits: 1)
let addressPart = String(parts[0])
let addressPart = String(parts.first ?? "")
let prefixLength: Int
if parts.count == 2 {
guard let parsed = Int(parts[1]), (0...32).contains(parsed) else {
@@ -183,7 +183,7 @@ enum RuleMatch: Equatable, Hashable, Codable, Sendable {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw RuleMatchError.invalidCIDR(raw) }
let parts = trimmed.split(separator: "/", maxSplits: 1)
let addressPart = String(parts[0])
let addressPart = String(parts.first ?? "")
let prefixLength: Int
if parts.count == 2 {
guard let parsed = Int(parts[1]), (0...128).contains(parsed) else {
@@ -240,6 +240,29 @@ enum RuleMatchError: Error, Equatable, Sendable {
case invalidCIDR(String)
}
/// Source-independent routing identity; custom UUIDs continue to identify edits.
/// Callers supply valid RuleMatch values; decoding and input constructors validate them.
struct RuleIdentity: Codable, Equatable, Hashable, Sendable {
let action: RuleAction
let match: RuleMatch
init(action: RuleAction, match: RuleMatch) {
self.action = action
switch match {
case .domainExact(let value):
self.match = (try? RuleMatch(domainExact: value)) ?? match
case .domainSuffix(let value):
self.match = (try? RuleMatch(nationalDomainSuffix: value)) ?? match
case .ipv4CIDR(let value):
self.match = (try? RuleMatch(ipv4CIDR: value)) ?? match
case .ipv6CIDR(let value):
self.match = (try? RuleMatch(ipv6CIDR: value)) ?? match
}
}
var contentToken: String { "\(action.rawValue)|\(String(describing: match))" }
}
// MARK: - 来源身份
/// 内置规则来源身份(issue #63)。
@@ -310,10 +333,12 @@ struct ProxyRule: Codable, Equatable, Hashable, Sendable {
self.conflict = conflict
}
var identity: RuleIdentity { RuleIdentity(action: action, match: match) }
/// sslocal ACL 单行表示(ASCII)。域名后缀 `||host`、完整域名 `|host`、
/// CIDR 原样写入。
var aclLine: String {
switch match {
switch identity.match {
case .domainExact(let domain):
return "|\(domain)"
case .domainSuffix(let domain):
@@ -0,0 +1,53 @@
import Foundation
/// Coverage is explanatory, never a persistence rejection or a routing priority.
struct RuleRelationship: Equatable, Sendable {
enum Kind: Sendable { case absorption, shadowing }
enum Extent: Sendable { case full, partial }
let rule: RuleIdentity
let kind: Kind
let extent: Extent
let covering: [RuleIdentity]
}
/// Analyze the complete candidate set. Optional subjects limit explanation work
/// to custom entries at the existing validation seam without narrowing context.
struct RuleAnalysis: Equatable, Sendable {
let identities: [RuleIdentity]
let relationships: [RuleRelationship]
var contentVersion: String {
String(
ProxyACLDocument.digest(identities.map(\.contentToken).joined(separator: "\n")).prefix(12))
}
init(rules: [ProxyRule], subjects: [ProxyRule]? = nil) {
let identities = Array(Set(rules.map(\.identity))).sorted {
$0.contentToken < $1.contentToken
}
self.identities = identities
let targets = Set((subjects ?? rules).map(\.identity))
relationships = identities.filter { targets.contains($0) }.flatMap { target in
[RuleRelationship.Kind.absorption, .shadowing].compactMap { kind in
let covering = identities.filter { other in
guard other != target else { return false }
switch kind {
case .absorption:
guard other.action == target.action else { return false }
case .shadowing:
guard other.action != target.action,
RuleCoverage.winningAction(for: target.match) == other.action
else { return false }
}
return RuleCoverage.intersects(other.match, target.match)
}
guard !covering.isEmpty else { return nil }
return RuleRelationship(
rule: target, kind: kind,
extent: RuleCoverage.fullyCovered(target.match, by: covering.map(\.match))
? .full : .partial,
covering: covering)
}
}
}
}
@@ -67,6 +67,42 @@ extension ProxyRuntimeControllerTests {
"自定义直连规则应进入规则模式 ACL")
}
func testOppositeActionsPersistAndReorderingDoesNotRedeploy() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let settings = ProxySettings(
listen: ActivationFixture.listen, preferredMode: .rule,
ruleDefaultAction: .directWhenUnmatched, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
let rules = [
CustomRule(action: .direct, match: .domainSuffix("order.example")),
CustomRule(action: .proxy, match: .domainExact("a.order.example")),
CustomRule(action: .direct, match: .domainExact("a.order.example")),
]
let first = await controller.updateCustomRules(rules)
XCTAssertEqual(first, .saved)
XCTAssertEqual(try store.load(), rules, "保存保留用户 UUID 和全部意图")
let validation = try controller.ruleModeValidation()
XCTAssertTrue(
validation.relationships.contains {
$0.rule == rules[2].identity && $0.kind == .shadowing && $0.extent == .full
})
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let before = try activeACLContent(runtimeStore)
let unregisterBefore = agent.unregisterCount
let summary = controller.readCustomRuleSummary()
let reversed = Array(rules.reversed())
let reordered = await controller.updateCustomRules(reversed)
XCTAssertEqual(reordered, .saved)
XCTAssertEqual(try store.load(), reversed)
XCTAssertEqual(try activeACLContent(runtimeStore), before)
XCTAssertEqual(agent.unregisterCount, unregisterBefore)
XCTAssertEqual(controller.readCustomRuleSummary(), summary)
XCTAssertEqual(try controller.ruleModeValidation(), validation)
}
/// 校验拒绝:固定本地冲突整批不落地,旧规则保持不变。
func testUpdateCustomRulesRejectsFixedLocalConflictAndKeepsPreviousRules() async throws {
let seeded = try makeSeededCatalog()
@@ -15,6 +15,21 @@ final class CustomRuleMergeTests: XCTestCase {
CustomRule(action: action, match: match)
}
func testACLIdentityDoesNotDependOnRuleOrSourceOrder() {
let first = ProxyRule(action: .direct, match: .domainSuffix("z.example"), source: chinaSource)
let equivalent = ProxyRule(
action: .direct, match: .domainSuffix("z.example"), source: gfwSource)
let second = ProxyRule(action: .proxy, match: .domainExact("a.example"), source: gfwSource)
let url = URL(fileURLWithPath: "/tmp/rules-order.acl")
for action in RuleDefaultAction.allCases {
let expected = ProxyACLDocument.rule(at: url, defaultAction: action, rules: [first, second])
let reordered = ProxyACLDocument.rule(
at: url, defaultAction: action, rules: [second, equivalent, first])
XCTAssertEqual(expected.content, reordered.content)
XCTAssertEqual(expected.sha256, reordered.sha256)
}
}
/// 「未匹配时代理」:中国直连候选 + 自定义直连进 bypass_list;自定义代理
/// 不写 proxy_list(默认已代理)。
func testProxyWhenUnmatchedMergesChinaDirectAndCustomDirect() throws {
@@ -76,8 +91,8 @@ final class CustomRuleMergeTests: XCTestCase {
XCTAssertTrue(acl.content.hasPrefix("[bypass_all]"))
}
/// 被 GFWList 域名代理遮蔽的自定义直连不进入 ACL。
func testShadowedCustomDirectIsExcludedFromACL() throws {
/// 被遮蔽条目保留;ACL 优先级决定其实际作用。
func testShadowedCustomDirectIsRetainedAndExplained() throws {
let builtIn = [
ProxyRule(
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
@@ -90,19 +105,18 @@ final class CustomRuleMergeTests: XCTestCase {
custom: customRules, builtIn: builtIn, defaultAction: .directWhenUnmatched)
let merged = builtIn + result.accepted
XCTAssertEqual(result.rejected.map(\.reason), [.shadowedByDomainProxy])
XCTAssertTrue(result.rejected.isEmpty)
XCTAssertEqual(result.relationships.first?.extent, .full)
let acl = ProxyACLDocument.rule(
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"),
defaultAction: .directWhenUnmatched,
rules: merged)
XCTAssertFalse(
acl.content.contains("sub.blocked.example"),
"被遮蔽的自定义规则不得写入 ACL")
XCTAssertTrue(acl.content.contains("sub.blocked.example"), "保留可表达意图,路由由 sslocal 优先级决定")
}
/// 单模式遮蔽:在「未匹配时代理」下有效(不被遮蔽),在「未匹配时直连」下
/// 被 GFWList 代理遮蔽并返回原因。保存允许(另一模式可生效),编译按当前
/// 模式过滤。
/// 模式解释。
func testOneModeShadowedRuleIsValidInTheOtherMode() throws {
let gfwBuiltIn = [
ProxyRule(
@@ -124,9 +138,9 @@ final class CustomRuleMergeTests: XCTestCase {
let directDefault = CustomRuleValidator.validate(
custom: customRules, builtIn: gfwBuiltIn, defaultAction: .directWhenUnmatched)
XCTAssertEqual(directDefault.rejected.map(\.reason), [.shadowedByDomainProxy])
XCTAssertFalse(directDefault.rejected[0].explanation.isEmpty, "返回可解释原因")
XCTAssertTrue(directDefault.accepted.isEmpty, "被遮蔽规则不得进入编译结果")
XCTAssertTrue(directDefault.rejected.isEmpty)
XCTAssertEqual(directDefault.relationships.first?.kind, .shadowing)
XCTAssertEqual(directDefault.accepted.count, 1)
}
/// 全局模式 ACL 不加载自定义规则(issue #66 AC3)。
@@ -52,6 +52,28 @@ final class CustomRuleStoreTests: XCTestCase {
XCTAssertEqual(try store.load(), rules)
}
func testStoredMatchIsNormalizedWithoutChangingCustomIdentity() throws {
let rule = CustomRule(action: .direct, match: .ipv4CIDR("203.0.113.15/24"))
try store.save([rule])
let loaded = try XCTUnwrap(store.load().first)
XCTAssertEqual(loaded.id, rule.id)
XCTAssertEqual(loaded.match, .ipv4CIDR("203.0.113.0/24"))
XCTAssertEqual(loaded.identity, rule.identity)
}
func testMalformedStoredCIDRReportsCorruptionWithoutRewritingDocument() throws {
for match in [RuleMatch.ipv4CIDR("/"), .ipv6CIDR("/")] {
try store.save([CustomRule(action: .direct, match: match)])
let before = try Data(contentsOf: store.fileURL)
XCTAssertThrowsError(try store.load()) { error in
guard case .corrupt = error as? CustomRuleStoreError else {
return XCTFail("Expected corrupt document, got \(error)")
}
}
XCTAssertEqual(try Data(contentsOf: store.fileURL), before)
}
}
func testCorruptFileThrowsCorrupt() throws {
try Data("not-json".utf8).write(to: store.fileURL)
@@ -3,7 +3,7 @@ import XCTest
@testable import ShadowsocksX_NG2
/// 自定义规则校验(issue #66 AC2):固定本地冲突、ACL 优先级遮蔽与重复拒绝,
/// 自定义规则校验(issue #66 AC2):固定本地冲突、ACL 优先级说明与重复拒绝,
/// 并返回可解释原因。
final class CustomRuleValidatorTests: XCTestCase {
private let customSource = RuleSourceIdentity(
@@ -21,6 +21,20 @@ final class CustomRuleValidatorTests: XCTestCase {
CustomRule(id: id, action: action, match: match)
}
func testOppositeActionsAreRetainedRegardlessOfCustomOrder() throws {
let rules = [
rule(.direct, try RuleMatch(domainExact: "a.example.com")),
rule(.proxy, try RuleMatch(domainSuffix: "example.com")),
]
let first = CustomRuleValidator.validate(custom: rules, defaultAction: .directWhenUnmatched)
let reversed = CustomRuleValidator.validate(
custom: Array(rules.reversed()), defaultAction: .directWhenUnmatched)
XCTAssertTrue(first.rejected.isEmpty)
XCTAssertTrue(reversed.rejected.isEmpty)
XCTAssertEqual(first, reversed)
XCTAssertEqual(first.accepted.count, 2)
}
// MARK: 有效规则
func testValidDomainAndCIDRRulesAreAccepted() throws {
@@ -36,8 +50,19 @@ final class CustomRuleValidatorTests: XCTestCase {
XCTAssertTrue(result.rejected.isEmpty)
XCTAssertEqual(result.accepted.count, 4)
XCTAssertEqual(result.accepted.first?.action, .proxy)
XCTAssertEqual(result.accepted.first?.aclLine, "||blocked.example")
XCTAssertTrue(
result.accepted.contains { $0.aclLine == "||blocked.example" && $0.action == .proxy })
}
func testMappedIPv6CannotBypassFixedLocalConflictValidation() throws {
let privateRule = rule(.proxy, try RuleMatch(ipv6CIDR: "::ffff:192.168.1.2"))
let broadRule = rule(.proxy, try RuleMatch(ipv6CIDR: "::ffff:0.0.0.0/96"))
let publicRule = rule(.proxy, try RuleMatch(ipv6CIDR: "::ffff:203.0.113.8"))
let result = CustomRuleValidator.validate(
custom: [privateRule, publicRule, broadRule], defaultAction: .directWhenUnmatched)
XCTAssertEqual(result.rejected.count, 2)
XCTAssertTrue(result.rejected.allSatisfy { $0.reason == .conflictsWithFixedLocalScope })
XCTAssertEqual(result.accepted, [publicRule.proxyRule])
}
// MARK: 固定本地冲突
@@ -107,7 +132,7 @@ final class CustomRuleValidatorTests: XCTestCase {
// MARK: 遮蔽
func testDomainDirectShadowedByBroaderDomainProxyIsRejectedWhenProxySideWritten() throws {
func testDomainDirectShadowedByBroaderDomainProxyIsExplainedWhenProxySideWritten() throws {
let builtIn = [
ProxyRule(
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
@@ -117,9 +142,9 @@ final class CustomRuleValidatorTests: XCTestCase {
let result = CustomRuleValidator.validate(
custom: custom, builtIn: builtIn, defaultAction: .directWhenUnmatched)
XCTAssertEqual(result.accepted.count, 0)
XCTAssertEqual(result.rejected.map(\.reason), [.shadowedByDomainProxy])
XCTAssertTrue(result.rejected[0].explanation.contains("blocked.example"))
XCTAssertEqual(result.accepted.count, 1)
XCTAssertTrue(result.rejected.isEmpty)
XCTAssertEqual(result.relationships.first?.extent, .full)
}
func testDomainDirectNotShadowedWhenProxySideNotWritten() throws {
@@ -137,7 +162,7 @@ final class CustomRuleValidatorTests: XCTestCase {
XCTAssertEqual(result.accepted.count, 1)
}
func testIPProxyShadowedByBroaderIPDirectIsRejectedWhenProxySideWritten() throws {
func testIPProxyShadowedByBroaderIPDirectIsExplainedWhenProxySideWritten() throws {
let builtIn = [
ProxyRule(
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: chinaSource)
@@ -147,7 +172,9 @@ final class CustomRuleValidatorTests: XCTestCase {
let result = CustomRuleValidator.validate(
custom: custom, builtIn: builtIn, defaultAction: .directWhenUnmatched)
XCTAssertEqual(result.rejected.map(\.reason), [.shadowedByIPDirect])
XCTAssertTrue(result.rejected.isEmpty)
XCTAssertEqual(result.relationships.first?.kind, .shadowing)
XCTAssertEqual(result.relationships.first?.extent, .full)
}
func testIPProxyNotRejectedWhenProxySideNotWritten() throws {
@@ -188,9 +215,9 @@ final class CustomRuleValidatorTests: XCTestCase {
let result = CustomRuleValidator.validate(
custom: custom, defaultAction: .directWhenUnmatched)
XCTAssertEqual(result.accepted.count, 1, "只保留先通过校验的代理规则")
XCTAssertEqual(result.rejected.map(\.reason), [.shadowedByDomainProxy])
XCTAssertEqual(result.rejected[0].rule.action, .direct)
XCTAssertEqual(result.accepted.count, 2)
XCTAssertTrue(result.rejected.isEmpty)
XCTAssertEqual(result.relationships.first?.rule.action, .direct)
}
func testCustomToCustomIPShadowingIsDetected() throws {
@@ -202,8 +229,10 @@ final class CustomRuleValidatorTests: XCTestCase {
let result = CustomRuleValidator.validate(
custom: custom, defaultAction: .directWhenUnmatched)
XCTAssertEqual(result.rejected.map(\.reason), [.shadowedByIPDirect])
XCTAssertEqual(result.rejected[0].rule.action, .proxy)
XCTAssertTrue(result.rejected.isEmpty)
XCTAssertEqual(result.relationships.first?.kind, .shadowing)
XCTAssertEqual(result.relationships.first?.extent, .full)
XCTAssertEqual(result.relationships.first?.rule.action, .proxy)
}
// MARK: 重复
@@ -218,8 +247,8 @@ final class CustomRuleValidatorTests: XCTestCase {
let result = CustomRuleValidator.validate(
custom: custom, defaultAction: .directWhenUnmatched)
XCTAssertEqual(result.accepted.count, 1)
XCTAssertEqual(result.rejected.map(\.reason), [.duplicate])
XCTAssertTrue(result.accepted.isEmpty)
XCTAssertEqual(result.rejected.map(\.reason), [.duplicate, .duplicate])
}
// MARK: 覆盖判定
+3 -2
View File
@@ -170,8 +170,9 @@ final class ProxyACLTests: XCTestCase {
XCTAssertTrue(acl.isWellFormed)
XCTAssertEqual(acl.summary, "rule-direct-default")
XCTAssertTrue(acl.content.hasPrefix("[bypass_all]\n[bypass_list]\n"))
XCTAssertTrue(acl.content.contains("[proxy_list]\n||blocked.example\n"))
XCTAssertTrue(acl.content.contains("||also-blocked.example\n"))
XCTAssertEqual(
acl.content.components(separatedBy: "[proxy_list]\n").last,
"||also-blocked.example\n||blocked.example\n")
XCTAssertTrue(acl.content.contains("||unshadowed.example\n"), "未遮蔽例外参与编译进 bypass_list")
XCTAssertFalse(acl.content.contains("[proxy_all]"))
// 不无条件并集中国来源。
@@ -0,0 +1,103 @@
import XCTest
@testable import ShadowsocksX_NG2
final class RuleAnalysisTests: XCTestCase {
func testDomainShadowingSeparatesFullCoverageFromPartialOverlap() {
let broad = CustomRule(action: .direct, match: .domainSuffix("example.com"))
let narrow = CustomRule(action: .proxy, match: .domainExact("a.example.com"))
let partial = RuleAnalysis(rules: [broad.proxyRule, narrow.proxyRule])
XCTAssertEqual(
partial.relationships,
[
RuleRelationship(
rule: broad.identity, kind: .shadowing, extent: .partial,
covering: [narrow.identity])
])
let full = RuleAnalysis(rules: [
broad.proxyRule,
CustomRule(action: .proxy, match: .domainSuffix("example.com")).proxyRule,
])
XCTAssertEqual(full.relationships.first?.extent, .full)
}
func testIdentityNormalizesContentWithoutUsingCustomUUIDOrMergingFamilies() {
let host = CustomRule(action: .proxy, match: .ipv4CIDR("203.0.113.8"))
let cidr = CustomRule(action: .proxy, match: .ipv4CIDR("203.0.113.8/32"))
XCTAssertNotEqual(host.id, cidr.id)
XCTAssertEqual(host.identity, cidr.identity)
XCTAssertEqual(host.contentToken, cidr.contentToken)
XCTAssertNotEqual(
host.identity,
CustomRule(action: .direct, match: host.match).identity)
XCTAssertNotEqual(
host.identity,
CustomRule(action: .proxy, match: .ipv6CIDR("::ffff:203.0.113.8/128")).identity)
XCTAssertEqual(
CustomRule(action: .direct, match: .domainSuffix(".Example.COM")).identity.match,
.domainSuffix("example.com"))
}
func testAnalysisAndVersionsDoNotDependOnSourceOrCustomOrder() {
let custom = [
CustomRule(action: .direct, match: .domainSuffix("example.com")),
CustomRule(action: .direct, match: .domainExact("a.example.com")),
CustomRule(action: .proxy, match: .domainExact("a.example.com")),
]
let sources = [
RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "test", label: "gfw"),
RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "test", label: "cn"),
]
let builtIn = sources.map {
ProxyRule(action: .proxy, match: .domainSuffix("a.example.com"), source: $0)
}
let expected = RuleAnalysis(rules: builtIn + custom.map(\.proxyRule))
for order in [custom, Array(custom.reversed()), [custom[1], custom[2], custom[0]]] {
for sourceOrder in [builtIn, Array(builtIn.reversed())] {
let actual = RuleAnalysis(rules: order.map(\.proxyRule) + sourceOrder)
XCTAssertEqual(actual, expected)
XCTAssertEqual(actual.contentVersion, expected.contentVersion)
XCTAssertEqual(CustomRuleSummary.summarizing(order), CustomRuleSummary.summarizing(custom))
for action in RuleDefaultAction.allCases {
XCTAssertEqual(
CustomRuleValidator.validate(
custom: order, builtIn: sourceOrder, defaultAction: action),
CustomRuleValidator.validate(custom: custom, builtIn: builtIn, defaultAction: action))
}
}
}
XCTAssertTrue(expected.relationships.contains { $0.kind == .absorption && $0.extent == .full })
XCTAssertTrue(
expected.relationships.contains { $0.kind == .shadowing && $0.extent == .partial })
}
func testIPCoverageCombinesNarrowerRangesAndKeepsPartialRemainder() {
let broad = CustomRule(action: .proxy, match: .ipv4CIDR("203.0.113.0/24"))
let lower = CustomRule(action: .direct, match: .ipv4CIDR("203.0.113.0/25"))
let upper = CustomRule(action: .direct, match: .ipv6CIDR("::ffff:203.0.113.128/121"))
XCTAssertEqual(
RuleAnalysis(rules: [broad.proxyRule, lower.proxyRule])
.relationships.first?.extent, .partial)
let full = RuleAnalysis(rules: [upper.proxyRule, broad.proxyRule, lower.proxyRule])
XCTAssertEqual(full.relationships.first?.extent, .full)
XCTAssertEqual(full.relationships.first?.covering.count, 2)
}
func testSuffixBoundaryAndMappedCoverageFollowSslocal() {
XCTAssertFalse(
RuleCoverage.domainIntersects(
.domainSuffix("example.com"), .domainExact("notexample.com")))
XCTAssertFalse(
RuleCoverage.domainCovers(
.domainExact("example.com"), .domainExact("a.example.com")))
XCTAssertTrue(
RuleCoverage.ipCovers(
.ipv4CIDR("203.0.113.0/24"), .ipv6CIDR("::ffff:203.0.113.8/128")))
XCTAssertTrue(
RuleCoverage.ipCovers(
.ipv6CIDR("::ffff:203.0.113.0/120"), .ipv4CIDR("203.0.113.8/32")))
XCTAssertFalse(
RuleCoverage.ipIntersects(
.ipv4CIDR("203.0.113.0/24"), .ipv6CIDR("2001:db8::/32")))
}
}
@@ -0,0 +1,115 @@
import Darwin
import XCTest
@testable import ShadowsocksX_NG2
extension RealSslocalSmokeTests {
func testRuleSemanticsWithProxyDefaultAndReversedOrder() throws {
try assertRuleSemantics(defaultHeader: "[proxy_all]", unmatchedProxies: true)
}
func testRuleSemanticsWithDirectDefaultAndReversedOrder() throws {
try assertRuleSemantics(defaultHeader: "[bypass_all]", unmatchedProxies: false)
}
/// Test-only dual-list ACL: product proxy-default intentionally omits proxy_list.
/// Only loopback IPs and reserved .invalid domains are requested. Opposite
/// defaults distinguish actual rule hits from an unmatched default result.
private func assertRuleSemantics(defaultHeader: String, unmatchedProxies: Bool) throws {
let echo = try LoopbackEchoServer()
let exit = try ConnectionCountingServer()
let bypass = [
"||overlap.invalid", "||partial.invalid",
]
let proxy = [
"127.0.0.0/8", "::ffff:127.0.0.0/120",
"||overlap.invalid", "|a.partial.invalid",
"|exact.invalid", "||suffix.invalid",
]
for reversed in [false, true] {
let ports = try grabThreeListenPorts(excluding: [echo.port, exit.port])
let listen = SslocalListenSettings(socksPort: ports[0], httpPort: ports[1])
let bypass = bypass + [reversed ? "::ffff:127.0.0.1/128" : "127.0.0.1/32"]
let lines =
[defaultHeader, "[bypass_list]"]
+ (reversed ? Array(bypass.reversed()) : bypass) + ["[proxy_list]"]
+ (reversed ? Array(proxy.reversed()) : proxy)
let document = SslocalRuntimeDocument(
servers: [
SslocalServerDocument(
id: "rule-semantics", server: "127.0.0.1", serverPort: exit.port,
password: "smoke-password", method: "aes-256-gcm", plugin: nil, pluginOpts: nil)
],
listen: listen,
acl: ProxyACLDocument(
path: aclFileURL.path, summary: "rule-semantics",
content: lines.joined(separator: "\n") + "\n"))
let wrapper = try launchWrapper(document)
defer { if wrapper.isRunning { kill(wrapper.processIdentifier, SIGTERM) } }
try awaitGlobalInboundsReady(listen: listen, document: document)
try assertDomainRuleSemantics(
listen: listen, exit: exit, port: echo.port, unmatchedProxies: unmatchedProxies)
let before = exit.connectionCount
XCTAssertEqual(
performSocksConnectReply(
socksPort: listen.socksPort,
request: mappedConnectRequest([127, 0, 0, 1], port: echo.port)), 0)
XCTAssertEqual(
performSocksConnectReply(
socksPort: listen.socksPort,
request: socksIPv4ConnectRequest([127, 0, 0, 1], port: echo.port)), 0)
XCTAssertEqual(exit.connectionCount, before, "Mapped and IPv4 bypass both protect loopback")
try assertRuleRoute(
socksPort: listen.socksPort, exit: exit,
request: mappedConnectRequest([127, 0, 0, 2], port: echo.port), proxies: true,
description: "IP overlap keeps remaining proxy targets")
stopWrapperAndAssertCleanExit(wrapper, description: "rule semantics wrapper exits")
}
}
private func assertDomainRuleSemantics(
listen: SslocalListenSettings, exit: ConnectionCountingServer, port: Int, unmatchedProxies: Bool
) throws {
for host in [
"overlap.invalid", "a.overlap.invalid", "a.partial.invalid",
"exact.invalid", "suffix.invalid", "a.suffix.invalid",
] {
try assertRuleRoute(
socksPort: listen.socksPort, exit: exit,
request: socksDomainConnectRequest(host: host, port: port), proxies: true,
description: host)
}
try assertRuleRoute(
socksPort: listen.socksPort, exit: exit,
request: socksDomainConnectRequest(host: "b.partial.invalid", port: port),
proxies: false, description: "partial overlap keeps remaining direct targets")
for host in ["a.exact.invalid", "notsuffix.invalid"] {
try assertRuleRoute(
socksPort: listen.socksPort, exit: exit,
request: socksDomainConnectRequest(host: host, port: port),
proxies: unmatchedProxies, description: "unmatched boundary: \(host)")
}
}
private func assertRuleRoute(
socksPort: Int, exit: ConnectionCountingServer, request: [UInt8],
proxies: Bool, description: String
) throws {
let before = exit.connectionCount
let reply = performSocksConnectReply(socksPort: socksPort, request: request)
XCTAssertNotNil(reply, "SOCKS routing request completed: \(description)")
if proxies {
XCTAssertTrue(try waitForCondition(timeout: 5) { exit.connectionCount > before }, description)
} else {
Thread.sleep(forTimeInterval: 0.2)
XCTAssertEqual(exit.connectionCount, before, description)
}
}
private func mappedConnectRequest(_ octets: [UInt8], port: Int) -> [UInt8] {
var request: [UInt8] = [5, 1, 0, 4]
request += Array(repeating: 0, count: 10) + [255, 255] + octets
withUnsafeBytes(of: UInt16(port).bigEndian) { request.append(contentsOf: $0) }
return request
}
}