mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 15:43:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: 覆盖判定
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user