From 280a3fe6fed1688466262f656874b79d2fdc23f4 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Fri, 2 Oct 2026 16:34:52 +0800 Subject: [PATCH] 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. --- .../ProxyRuntimeController+CustomRules.swift | 3 +- .../App/ProxyRuntimeController+Mode.swift | 10 +- ShadowsocksX-NG2/Domain/CustomRule.swift | 6 +- .../Domain/CustomRuleValidator.swift | 191 +++++++----------- ShadowsocksX-NG2/Domain/ProxyACL.swift | 11 +- ShadowsocksX-NG2/Domain/ProxyRule.swift | 39 +++- ShadowsocksX-NG2/Domain/RuleAnalysis.swift | 53 +++++ .../Tests/CustomRuleControllerTests.swift | 36 ++++ .../Tests/CustomRuleMergeTests.swift | 34 +++- .../Tests/CustomRuleStoreTests.swift | 22 ++ .../Tests/CustomRuleValidatorTests.swift | 61 ++++-- ShadowsocksX-NG2/Tests/ProxyACLTests.swift | 5 +- .../Tests/RuleAnalysisTests.swift | 103 ++++++++++ .../RealSslocalSmokeTests+RuleSemantics.swift | 115 +++++++++++ 14 files changed, 527 insertions(+), 162 deletions(-) create mode 100644 ShadowsocksX-NG2/Domain/RuleAnalysis.swift create mode 100644 ShadowsocksX-NG2/Tests/RuleAnalysisTests.swift create mode 100644 ShadowsocksX-NG2/Tests/Smoke/RealSslocalSmokeTests+RuleSemantics.swift diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift index e191256a..df3681f7 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift @@ -92,8 +92,7 @@ extension ProxyRuntimeController { } /// 保存前校验(issue #66 AC2):固定本地冲突与重复整批拒绝并返回可解释原因。 - /// 单模式遮蔽的规则允许保存(在另一模式可生效),编译时按当前模式过滤并 - /// 通过 `ruleModeValidation` 返回原因;不把无效规则标为生效。 + /// 同行动覆盖和相反行动遮蔽不阻止保存;编译时按当前骨架解释完整集合。 func validateCustomRulesForPersistence(_ rules: [CustomRule]) -> ( acceptedRules: [CustomRule], rejected: [RejectedCustomRule] ) { diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift index d1750bdd..32145c27 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift @@ -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) } } diff --git a/ShadowsocksX-NG2/Domain/CustomRule.swift b/ShadowsocksX-NG2/Domain/CustomRule.swift index cd045969..1d3e6040 100644 --- a/ShadowsocksX-NG2/Domain/CustomRule.swift +++ b/ShadowsocksX-NG2/Domain/CustomRule.swift @@ -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 } } diff --git a/ShadowsocksX-NG2/Domain/CustomRuleValidator.swift b/ShadowsocksX-NG2/Domain/CustomRuleValidator.swift index 09d5a2f0..c97b3ded 100644 --- a/ShadowsocksX-NG2/Domain/CustomRuleValidator.swift +++ b/ShadowsocksX-NG2/Domain/CustomRuleValidator.swift @@ -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() - // 遮蔽判定集合:先通过固定本地/重复检查的规则才可能进入 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 - } - } } diff --git a/ShadowsocksX-NG2/Domain/ProxyACL.swift b/ShadowsocksX-NG2/Domain/ProxyACL.swift index 969c0e6b..439273c7 100644 --- a/ShadowsocksX-NG2/Domain/ProxyACL.swift +++ b/ShadowsocksX-NG2/Domain/ProxyACL.swift @@ -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) diff --git a/ShadowsocksX-NG2/Domain/ProxyRule.swift b/ShadowsocksX-NG2/Domain/ProxyRule.swift index 886e2277..e69c3668 100644 --- a/ShadowsocksX-NG2/Domain/ProxyRule.swift +++ b/ShadowsocksX-NG2/Domain/ProxyRule.swift @@ -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): diff --git a/ShadowsocksX-NG2/Domain/RuleAnalysis.swift b/ShadowsocksX-NG2/Domain/RuleAnalysis.swift new file mode 100644 index 00000000..0b09f8c1 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/RuleAnalysis.swift @@ -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) + } + } + } +} diff --git a/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift index e4bd0800..ce17b18d 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift @@ -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() diff --git a/ShadowsocksX-NG2/Tests/CustomRuleMergeTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleMergeTests.swift index f442cd0b..c6628460 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleMergeTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleMergeTests.swift @@ -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)。 diff --git a/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift index 3dd99ae7..655698a2 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleStoreTests.swift @@ -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) diff --git a/ShadowsocksX-NG2/Tests/CustomRuleValidatorTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleValidatorTests.swift index 68d33957..f79132b4 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleValidatorTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleValidatorTests.swift @@ -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: 覆盖判定 diff --git a/ShadowsocksX-NG2/Tests/ProxyACLTests.swift b/ShadowsocksX-NG2/Tests/ProxyACLTests.swift index b52941eb..3b5896fd 100644 --- a/ShadowsocksX-NG2/Tests/ProxyACLTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyACLTests.swift @@ -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]")) // 不无条件并集中国来源。 diff --git a/ShadowsocksX-NG2/Tests/RuleAnalysisTests.swift b/ShadowsocksX-NG2/Tests/RuleAnalysisTests.swift new file mode 100644 index 00000000..6cd5a576 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/RuleAnalysisTests.swift @@ -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"))) + } + +} diff --git a/ShadowsocksX-NG2/Tests/Smoke/RealSslocalSmokeTests+RuleSemantics.swift b/ShadowsocksX-NG2/Tests/Smoke/RealSslocalSmokeTests+RuleSemantics.swift new file mode 100644 index 00000000..4b48e843 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Smoke/RealSslocalSmokeTests+RuleSemantics.swift @@ -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 + } +}