mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-03 01:23:43 +00:00
refactor(rules): slim built-in snapshots
This commit is contained in:
+5
-5
@@ -15,14 +15,14 @@
|
||||
- **Proxy mode**: The user-facing way traffic is routed through the local proxy: rule mode, global (public targets default through Shadowsocks, fixed local targets stay direct via a minimal ACL), or direct mode through an ACL-backed local SOCKS endpoint. All three project the local SOCKS and HTTP inbounds as the system-proxy target.
|
||||
- **Rule mode**: The ACL-backed routing mode in which built-in (and later custom) rules choose proxy versus direct per target; the unmatched default action is a separate persisted sub-option.
|
||||
- **Rule default action**: The rule-mode sub-option that decides proxy versus direct for targets no rule matches. Factory default is proxy-when-unmatched. _Avoid_: fallback (ambiguous).
|
||||
- **Built-in rule snapshot**: An app-shipped, pinned rule set produced by an explicit maintainer update; ordinary builds only read local snapshots and never fetch or convert. Three sources ship today: **geolocation-cn** (China domain direct candidates), **china-ipv4** (China mainland IPv4 CIDR direct candidates from gaoyifan/china-operator-ip), and **gfwlist** (proxy candidates from the official Base64 AutoProxy GFWList; only rules losslessly expressible as target domains, with `@@` exceptions shadowed by a broader proxy rule omitted and reported). Each snapshot carries its own source identity, digest, license, attribution, and conversion loss report.
|
||||
- **Built-in rule snapshot**: An app-shipped, pinned rule set produced by an explicit maintainer update; ordinary builds only read local snapshots and never fetch or convert. Three sources ship today: **geolocation-cn** (China domain direct candidates), **china-ipv4** (China mainland IPv4 CIDR direct candidates from gaoyifan/china-operator-ip), and **gfwlist** (proxy candidates from the official Base64 AutoProxy GFWList; only rules losslessly expressible as target domains, with `@@` exceptions shadowed by a broader proxy rule omitted). A snapshot ships only the rules that can decide routing; same-action absorbed entries and opposite-action shadowed exceptions are omitted at conversion time and never re-enter matching when a broader rule is later disabled. Each snapshot carries one source identity in metadata for the whole set, plus digest, license, attribution, and conversion loss report. Schema 2 and converter 2.0.0 ship compact JSON rules containing only action and match; old snapshots and rule counts outside the generator bounds fail loading.
|
||||
- **Custom rule**: A user-owned proxy or direct rule with a stable opaque identity and an exact-domain, domain-suffix, or IPv4/IPv6 address-range match; list order carries no routing priority. Same-action absorption and opposite-action shadowing do not erase saved rules or imply that they determine routing; rules violating the fixed local bypass policy are invalid. _Avoid_: user rule.
|
||||
- **Rule identity**: The normalized match condition together with its routing action, shared by equivalent entries from different sources. It is distinct from the persistent identity of an editable custom entry; an individual IP address is equivalent to its full-length CIDR.
|
||||
- **Rule source membership**: The collection of sources containing a rule identity; browsing merges equivalent entries while retaining every source membership. Removing a custom entry does not remove matching built-in entries.
|
||||
- **Rule disablement**: The user's persisted exclusion of a normalized match condition and routing action across all rule sources; it excludes that rule identity without removing source entries or excluding broader rules covering the same targets. The fixed local bypass policy cannot be disabled.
|
||||
- **Rule absorption**: A same-action coverage relationship in which a broader rule makes a narrower rule redundant for routing; excluding the broader rule makes surviving narrower rules eligible for matching again. It is distinct from opposite-action shadowing and does not erase the user's saved custom rule.
|
||||
- **Rule shadowing**: An opposite-action coverage relationship in the analyzed rule collection; it may be complete or partial and may involve the fixed local bypass policy. It is distinct from same-action absorption and does not imply runtime activation.
|
||||
- **Snapshot conversion report**: The conversion counts, omissions, rejections, and notes recorded when a built-in rule snapshot was produced. It describes snapshot creation rather than the current collection's coverage relationships or runtime state.
|
||||
- **Rule absorption**: A same-action coverage relationship in the analyzed rule collection in which a broader rule makes a narrower rule redundant for routing; excluding the broader rule makes surviving narrower rules eligible for matching again. Snapshots do not preserve absorbed candidates, so built-in entries omitted at conversion stay omitted even when the broader rule is disabled. It is distinct from opposite-action shadowing and does not erase the user's saved custom rule.
|
||||
- **Rule shadowing**: An opposite-action coverage relationship in the analyzed rule collection; it may be complete or partial and may involve the fixed local bypass policy. Built-in shadowed exceptions are omitted from snapshots and are not restored when their blockers are disabled. It is distinct from same-action absorption and does not imply runtime activation.
|
||||
- **Snapshot conversion report**: The numeric conversion counts, omissions, rejections, and absorbed total recorded when a built-in rule snapshot was produced. It contains only converted and absorbed totals plus skipped/rejected category counts; shadowed exceptions are counted only as absorbed. It never lists individual upstream entries. It describes snapshot creation rather than the current collection's coverage relationships or runtime state.
|
||||
- **Rule document session**: The app-process owner of the final saved custom-rule document. Atomic persistence and rollback update this fact; browsing and runtime projection consume it without rereading the file after a commit. A new app session reads the persisted document again.
|
||||
- **Built-in rule snapshots**: The app-process source module shared by browsing and runtime projection. It loads each bundled source on demand, shares concurrent attempts, retains successful snapshots for the process lifetime, and retries a failed source only on an explicit retry request.
|
||||
- **Offline rule test**: A rule-only explanation for a supplied domain, IP address, or URL host that returns proxy, direct, or no matching rule, independent of proxy mode, runtime state, and rule default action. It performs no DNS resolution or connectivity check and explicitly leaves DNS-dependent IP rules untested for domain inputs.
|
||||
@@ -113,6 +113,6 @@
|
||||
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available.
|
||||
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
|
||||
- Runtime file deployment prepares contract bytes once and still reads the actual disk contract before choosing actions. ACL digest-manifest hits avoid reading the variant body; a miss reads the existing variant once and reuses those bytes if rollback is needed. An unchanged active-link target is preserved. The synchronous transaction records only completed mutations and attempts every recorded restoration on failure, reporting any restoration failure explicitly. A decoded contract with no ACL body can restore only an existing regular variant file; a missing or unsafe variant fails before publishing its link or contract. Digest metadata is updated only after contract persistence succeeds.
|
||||
- The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots (including preserved expressible absorbed candidates), and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or retrying clears prior results, and generation/version checks reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton, restoring preserved expressible absorbed candidates and GFWList exceptions when their blockers are disabled. Commits return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The operation area counts selected custom memberships and offers single or batch deletion. Workflow preparation captures an immutable custom UUID set and collection version for one confirmation; filtering clears hidden selections, and later selection changes cannot expand the captured set. Confirmation rejects stale or incomplete facts and uses the same whole-document transaction, retaining builtin memberships and all disabled identities, including orphan records. Success rebuilds source counts, relationships and selection and invalidates the address test; unchanged ACLs do not restart runtime. Cancellation only discards the confirmation.
|
||||
- The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots, and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; sorting affects presentation only; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or retrying clears prior results, and generation/version checks reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton. Commits return the final saved document after deployment or rollback and use one fixed toolbar status through persistence, deployment, and in-memory collection preparation. They reject repeats while keeping browsing available, preserve the runtime switch intent, and deploy only if ACL identity changes. A changed final document rebuilds browsing analysis from the retained built-in inputs off the UI actor; an unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual changes, distinguishes applied, unchanged runtime, and deferred application, and expires after three seconds; failures remain dismissible with details and contextual retry or runtime-inspection actions. A failed preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Deployment failures restore the entire prior document and runtime; recovery failures remain explicit errors. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The operation area counts selected custom memberships and offers single or batch deletion. Workflow preparation captures an immutable custom UUID set and collection version for one confirmation; filtering clears hidden selections, and later selection changes cannot expand the captured set. Confirmation rejects stale or incomplete facts and uses the same whole-document transaction, retaining builtin memberships and all disabled identities, including orphan records. Success rebuilds source counts, relationships and selection and invalidates the address test; unchanged ACLs do not restart runtime. Cancellation only discards the confirmation.
|
||||
- The app presents in the adaptive app form: menu-bar-only after the last workspace or independent rule-report window closes, and with a Dock icon and app-switcher presence while either is open; hiding the app is not closing it and does not change the form; the GUI process keeps running with the status menu available regardless of the workspace window.
|
||||
- At launch the GUI presents the workspace window unless silent launch is on; after the workspace window is closed, the status menu is the only way to reopen it. Silent launch changes only window presentation: proxy runtime restoration happens at every launch regardless of it.
|
||||
|
||||
@@ -39,7 +39,6 @@ struct RulesReportView: View {
|
||||
Text(verbatim: "\(RulesCopy.text("已吸收")): \(report.absorbedCount)")
|
||||
counts(report.skipped, label: RulesCopy.text("已跳过"))
|
||||
counts(report.rejected, label: RulesCopy.text("已拒绝"))
|
||||
ForEach(report.notes, id: \.self) { Text(verbatim: $0) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -32,7 +32,7 @@ struct BuiltinRuleCatalog {
|
||||
|
||||
/// 供 ACL 编译的中国直连候选(`.cn` 后缀 + geolocation-cn + china-ipv4)。
|
||||
static func chinaDirectRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
|
||||
(snapshot.rules + snapshot.absorbed).filter { $0.action == .direct }
|
||||
snapshot.rules.filter { $0.action == .direct }
|
||||
}
|
||||
|
||||
/// 合并多份快照的直连候选;保持输入顺序(域名在前,CIDR 在后)。
|
||||
@@ -43,7 +43,7 @@ struct BuiltinRuleCatalog {
|
||||
/// 供 ACL 编译的 GFWList 候选(issue #65):可准确表达且未被遮蔽的规则
|
||||
/// (代理候选 + 未遮蔽例外),两种动作都参与编译。
|
||||
static func gfwlistRules(from snapshot: RuleSnapshot) -> [ProxyRule] {
|
||||
snapshot.rules + snapshot.absorbed
|
||||
snapshot.rules
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,7 @@ actor BuiltinRuleSnapshots {
|
||||
case .gfwlist: kind = .gfwlist
|
||||
case .custom, .fixed: throw RuleSnapshotError.missing
|
||||
}
|
||||
guard snapshot.metadata.source.kind == kind,
|
||||
(snapshot.rules + snapshot.absorbed).allSatisfy({ $0.source.kind == kind })
|
||||
guard snapshot.metadata.source.kind == kind
|
||||
else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") }
|
||||
return snapshot
|
||||
})
|
||||
|
||||
@@ -80,7 +80,6 @@ struct ChinaIPv4Converter {
|
||||
ruleCount: parsed.rules.count, previousRuleCount: previousRuleCount)
|
||||
|
||||
report.convertedCount = parsed.rules.count
|
||||
report.notes.append("china-ipv4-direct-candidates")
|
||||
|
||||
let metadata = RuleSnapshotMetadata(
|
||||
source: source,
|
||||
@@ -92,7 +91,6 @@ struct ChinaIPv4Converter {
|
||||
return RuleSnapshot(
|
||||
metadata: metadata,
|
||||
rules: parsed.rules,
|
||||
absorbed: [],
|
||||
lossReport: report)
|
||||
}
|
||||
|
||||
@@ -115,9 +113,7 @@ struct ChinaIPv4Converter {
|
||||
rules.append(
|
||||
ProxyRule(
|
||||
action: .direct,
|
||||
match: match,
|
||||
source: source,
|
||||
conflict: RuleConflictMetadata(originalEntry: line)))
|
||||
match: match))
|
||||
} catch {
|
||||
rejectedCount += 1
|
||||
report.incrementRejected("invalidCIDR")
|
||||
|
||||
@@ -28,7 +28,7 @@ struct CustomRule: Codable, Equatable, Hashable, Sendable, Identifiable {
|
||||
|
||||
/// 折叠为来源无关的运行时规则,参与 ACL 合并。
|
||||
var proxyRule: ProxyRule {
|
||||
ProxyRule(action: action, match: identity.match, source: source)
|
||||
ProxyRule(action: action, match: identity.match)
|
||||
}
|
||||
|
||||
var identity: RuleIdentity { RuleIdentity(action: action, match: match) }
|
||||
|
||||
@@ -211,16 +211,10 @@ struct GFWListConverter {
|
||||
// 被更宽(或同宽)代理规则遮蔽的 @@ 例外不写入无效 ACL 项。
|
||||
let shadowing = ShadowingAnalyzer.split(
|
||||
proxyRules: proxyRules, exceptionRules: exceptionRules)
|
||||
report.incrementSkipped("shadowedException", by: shadowing.shadowed.count)
|
||||
for (exception, shadowedBy) in shadowing.shadowed {
|
||||
report.notes.append(
|
||||
"shadowed-exception: \(exception.conflict.originalEntry) shadowed-by \(shadowedBy.conflict.originalEntry)"
|
||||
)
|
||||
}
|
||||
|
||||
let rules = RuleSet(rules: shadowing.keptProxy + shadowing.keptExceptions).rules
|
||||
report.convertedCount = rules.count
|
||||
report.absorbedCount = shadowing.shadowed.count
|
||||
report.absorbedCount = shadowing.shadowedCount
|
||||
try validateRuleCount(rules.count, previousRuleCount: previousRuleCount)
|
||||
|
||||
let metadata = RuleSnapshotMetadata(
|
||||
@@ -233,7 +227,6 @@ struct GFWListConverter {
|
||||
return RuleSnapshot(
|
||||
metadata: metadata,
|
||||
rules: rules,
|
||||
absorbed: shadowing.shadowed.map(\.exception),
|
||||
lossReport: report)
|
||||
}
|
||||
|
||||
@@ -252,68 +245,35 @@ struct GFWListConverter {
|
||||
report.incrementSkipped("comment")
|
||||
case .header:
|
||||
report.incrementSkipped("header")
|
||||
case .domainProxy(let host, let original):
|
||||
case .domainProxy(let host, _):
|
||||
sawCodeLine = true
|
||||
proxyRules.append(
|
||||
makeRule(action: .proxy, match: try RuleMatch(domainSuffix: host), original: original))
|
||||
case .domainException(let host, let original):
|
||||
ProxyRule(action: .proxy, match: try RuleMatch(domainSuffix: host)))
|
||||
case .domainException(let host, _):
|
||||
sawCodeLine = true
|
||||
exceptionRules.append(
|
||||
makeRule(action: .direct, match: try RuleMatch(domainSuffix: host), original: original))
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainSuffix: host)))
|
||||
case .urlPrefix, .urlPath, .filterOption, .wildcard, .regexp, .plainText,
|
||||
.singleLabelPrefix, .ipLiteral:
|
||||
sawCodeLine = true
|
||||
let label = unexpressibleLabel(for: entry)
|
||||
recordUnexpressible(
|
||||
label.category, note: label.note, original: label.original, report: &report)
|
||||
report.incrementSkipped(unexpressibleCategory(for: entry))
|
||||
}
|
||||
}
|
||||
|
||||
private struct UnexpressibleLabel {
|
||||
let category: String
|
||||
let note: String
|
||||
let original: String
|
||||
}
|
||||
|
||||
private func unexpressibleLabel(for entry: AutoProxyEntry) -> UnexpressibleLabel {
|
||||
private func unexpressibleCategory(for entry: AutoProxyEntry) -> String {
|
||||
switch entry {
|
||||
case .urlPrefix(let original, _):
|
||||
return UnexpressibleLabel(category: "urlPrefix", note: "url-prefix", original: original)
|
||||
case .urlPath(let original, _):
|
||||
return UnexpressibleLabel(category: "urlPath", note: "url-path", original: original)
|
||||
case .filterOption(let original, _):
|
||||
return UnexpressibleLabel(category: "filterOption", note: "filter-option", original: original)
|
||||
case .wildcard(let original, _):
|
||||
return UnexpressibleLabel(category: "wildcard", note: "wildcard", original: original)
|
||||
case .regexp(let original, _):
|
||||
return UnexpressibleLabel(category: "regexp", note: "regexp", original: original)
|
||||
case .plainText(let original, _):
|
||||
return UnexpressibleLabel(category: "plainText", note: "plain-text", original: original)
|
||||
case .singleLabelPrefix(let original, _):
|
||||
return UnexpressibleLabel(
|
||||
category: "singleLabelPrefix", note: "single-label-prefix", original: original)
|
||||
case .ipLiteral(let original, _):
|
||||
return UnexpressibleLabel(category: "ipLiteral", note: "ip-literal", original: original)
|
||||
default:
|
||||
return UnexpressibleLabel(category: "", note: "", original: "")
|
||||
case .urlPrefix: "urlPrefix"
|
||||
case .urlPath: "urlPath"
|
||||
case .filterOption: "filterOption"
|
||||
case .wildcard: "wildcard"
|
||||
case .regexp: "regexp"
|
||||
case .plainText: "plainText"
|
||||
case .singleLabelPrefix: "singleLabelPrefix"
|
||||
case .ipLiteral: "ipLiteral"
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
|
||||
private func recordUnexpressible(
|
||||
_ category: String, note: String, original: String, report: inout RuleConversionLossReport
|
||||
) {
|
||||
report.incrementSkipped(category)
|
||||
report.notes.append("unexpressible-\(note): \(original)")
|
||||
}
|
||||
|
||||
private func makeRule(action: RuleAction, match: RuleMatch, original: String) -> ProxyRule {
|
||||
ProxyRule(
|
||||
action: action,
|
||||
match: match,
|
||||
source: source,
|
||||
conflict: RuleConflictMetadata(originalEntry: original))
|
||||
}
|
||||
|
||||
private func validateRuleCount(_ ruleCount: Int, previousRuleCount: Int?) throws {
|
||||
guard ruleCount >= minimumRuleCount else {
|
||||
throw ConversionError.abnormalRuleCount(ruleCount)
|
||||
@@ -337,35 +297,27 @@ struct GFWListConverter {
|
||||
// MARK: - 例外遮蔽
|
||||
|
||||
/// 域名优先下(proxy_list 先于 bypass_list),被更宽代理规则覆盖的 `@@` 例外
|
||||
/// 写入 ACL 也不会生效。保留代理规则,例外逐项报告并不进入规则列表。
|
||||
/// 写入 ACL 也不会生效。保留代理规则,遮蔽例外只计数,不进入快照规则列表。
|
||||
enum ShadowingAnalyzer {
|
||||
struct Result {
|
||||
let keptProxy: [ProxyRule]
|
||||
let keptExceptions: [ProxyRule]
|
||||
let shadowed: [(exception: ProxyRule, shadowedBy: ProxyRule)]
|
||||
let shadowedCount: Int
|
||||
}
|
||||
|
||||
static func split(proxyRules: [ProxyRule], exceptionRules: [ProxyRule]) -> Result {
|
||||
var keptExceptions: [ProxyRule] = []
|
||||
var shadowed: [(ProxyRule, ProxyRule)] = []
|
||||
var shadowedCount = 0
|
||||
for exception in exceptionRules {
|
||||
if let blocker = proxyRules.first(where: { covers(proxy: $0, exception: exception) }) {
|
||||
let marked = ProxyRule(
|
||||
action: exception.action,
|
||||
match: exception.match,
|
||||
source: exception.source,
|
||||
conflict: RuleConflictMetadata(
|
||||
originalEntry: exception.conflict.originalEntry,
|
||||
absorbedBy: blocker.match,
|
||||
notes: exception.conflict.notes + ["shadowed-by-broader-proxy"]))
|
||||
shadowed.append((marked, blocker))
|
||||
if proxyRules.contains(where: { covers(proxy: $0, exception: exception) }) {
|
||||
shadowedCount += 1
|
||||
} else {
|
||||
keptExceptions.append(exception)
|
||||
}
|
||||
}
|
||||
return Result(
|
||||
keptProxy: proxyRules, keptExceptions: keptExceptions,
|
||||
shadowed: shadowed.map { (exception: $0.0, shadowedBy: $0.1) })
|
||||
shadowedCount: shadowedCount)
|
||||
}
|
||||
|
||||
/// 代理规则是否在域名匹配上覆盖例外(相等或更宽的后缀)。
|
||||
|
||||
@@ -175,16 +175,12 @@ struct GeolocationCNConverter {
|
||||
if !rules.contains(where: { $0.match == .domainSuffix("cn") }) {
|
||||
let cnRule = ProxyRule(
|
||||
action: .direct,
|
||||
match: .domainSuffix("cn"),
|
||||
source: source,
|
||||
conflict: RuleConflictMetadata(
|
||||
originalEntry: "synthesized:.cn-suffix", notes: ["synthesized-cn-suffix"]))
|
||||
match: .domainSuffix("cn"))
|
||||
rules.insert(cnRule, at: 0)
|
||||
report.notes.append("synthesized-cn-suffix")
|
||||
}
|
||||
let absorption = RuleCNabsorption.absorb(rules: rules)
|
||||
report.convertedCount = absorption.rules.count
|
||||
report.absorbedCount = absorption.absorbed.count
|
||||
report.absorbedCount = absorption.absorbedCount
|
||||
|
||||
let ruleCount = absorption.rules.count
|
||||
guard ruleCount >= minimumRuleCount else {
|
||||
@@ -204,16 +200,13 @@ struct GeolocationCNConverter {
|
||||
return RuleSnapshot(
|
||||
metadata: metadata,
|
||||
rules: absorption.rules,
|
||||
absorbed: absorption.absorbed,
|
||||
lossReport: report)
|
||||
}
|
||||
|
||||
private func makeRule(match: RuleMatch, original: String) -> ProxyRule {
|
||||
private func makeRule(match: RuleMatch) -> ProxyRule {
|
||||
ProxyRule(
|
||||
action: .direct,
|
||||
match: match,
|
||||
source: source,
|
||||
conflict: RuleConflictMetadata(originalEntry: original))
|
||||
match: match)
|
||||
}
|
||||
|
||||
/// 单条 typed 条目 → 规则;跳过/拒绝计入损失报告。返回 nil 表示不产出规则。
|
||||
@@ -235,20 +228,20 @@ struct GeolocationCNConverter {
|
||||
report.incrementSkipped("regexp")
|
||||
return nil
|
||||
case .domain(let value, _):
|
||||
return domainRule(value, original: "domain:\(value)", report: &report)
|
||||
return domainRule(value, report: &report)
|
||||
case .full(let value, _):
|
||||
return exactRule(value, original: "full:\(value)", report: &report)
|
||||
return exactRule(value, report: &report)
|
||||
case .bareDomain(let value, _):
|
||||
// 裸域名按 domain 后缀语义;`cn` 作为国家后缀单独引入。
|
||||
if value.lowercased() == "cn" {
|
||||
return domainRule(value, original: value, report: &report, allowNationalCN: true)
|
||||
return domainRule(value, report: &report, allowNationalCN: true)
|
||||
}
|
||||
return domainRule(value, original: value, report: &report)
|
||||
return domainRule(value, report: &report)
|
||||
}
|
||||
}
|
||||
|
||||
private func domainRule(
|
||||
_ value: String, original: String, report: inout RuleConversionLossReport,
|
||||
_ value: String, report: inout RuleConversionLossReport,
|
||||
allowNationalCN: Bool = false
|
||||
) -> ProxyRule? {
|
||||
do {
|
||||
@@ -258,7 +251,7 @@ struct GeolocationCNConverter {
|
||||
} else {
|
||||
match = try RuleMatch(domainSuffix: value)
|
||||
}
|
||||
return makeRule(match: match, original: original)
|
||||
return makeRule(match: match)
|
||||
} catch {
|
||||
report.incrementRejected("invalidDomain")
|
||||
return nil
|
||||
@@ -266,10 +259,10 @@ struct GeolocationCNConverter {
|
||||
}
|
||||
|
||||
private func exactRule(
|
||||
_ value: String, original: String, report: inout RuleConversionLossReport
|
||||
_ value: String, report: inout RuleConversionLossReport
|
||||
) -> ProxyRule? {
|
||||
do {
|
||||
return makeRule(match: try RuleMatch(domainExact: value), original: original)
|
||||
return makeRule(match: try RuleMatch(domainExact: value))
|
||||
} catch {
|
||||
report.incrementRejected("invalidDomain")
|
||||
return nil
|
||||
|
||||
@@ -273,7 +273,7 @@ enum RuleSourceKind: String, Codable, CaseIterable, Equatable, Hashable, Sendabl
|
||||
case custom = "custom"
|
||||
}
|
||||
|
||||
/// 规则来源身份:固定来源类别 + 上游版本 + 展示标签。冲突说明与快照元数据
|
||||
/// 规则来源身份:固定来源类别 + 上游版本 + 展示标签。自定义条目与快照元数据
|
||||
/// 共用此身份。
|
||||
struct RuleSourceIdentity: Codable, Equatable, Hashable, Sendable {
|
||||
let kind: RuleSourceKind
|
||||
@@ -289,48 +289,19 @@ struct RuleSourceIdentity: Codable, Equatable, Hashable, Sendable {
|
||||
var id: String { kind.rawValue }
|
||||
}
|
||||
|
||||
// MARK: - 冲突元数据
|
||||
|
||||
/// 冲突/覆盖说明元数据:记录原始条目、被谁吸收以及审查笔记。用于转换损失
|
||||
/// 与冲突解释,不参与运行时匹配。
|
||||
struct RuleConflictMetadata: Codable, Equatable, Hashable, Sendable {
|
||||
/// 转换前的原始条目文本(审计用)。
|
||||
let originalEntry: String
|
||||
/// 被哪条更宽规则吸收(如 `.cn` 后缀);未吸收为 nil。
|
||||
let absorbedBy: RuleMatch?
|
||||
/// 审查笔记(吸收、遮蔽、跳过原因等)。
|
||||
let notes: [String]
|
||||
|
||||
init(
|
||||
originalEntry: String,
|
||||
absorbedBy: RuleMatch? = nil,
|
||||
notes: [String] = []
|
||||
) {
|
||||
self.originalEntry = originalEntry
|
||||
self.absorbedBy = absorbedBy
|
||||
self.notes = notes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 规则
|
||||
|
||||
/// 一条来源无关的代理规则。
|
||||
struct ProxyRule: Codable, Equatable, Hashable, Sendable {
|
||||
let action: RuleAction
|
||||
let match: RuleMatch
|
||||
let source: RuleSourceIdentity
|
||||
let conflict: RuleConflictMetadata
|
||||
|
||||
init(
|
||||
action: RuleAction,
|
||||
match: RuleMatch,
|
||||
source: RuleSourceIdentity,
|
||||
conflict: RuleConflictMetadata = RuleConflictMetadata(originalEntry: "")
|
||||
match: RuleMatch
|
||||
) {
|
||||
self.action = action
|
||||
self.match = match
|
||||
self.source = source
|
||||
self.conflict = conflict
|
||||
}
|
||||
|
||||
var identity: RuleIdentity { RuleIdentity(action: action, match: match) }
|
||||
|
||||
@@ -17,7 +17,7 @@ struct RuleSet: Codable, Equatable, Sendable {
|
||||
var ordered: [ProxyRule] = []
|
||||
var seen = Set<String>()
|
||||
for rule in rules {
|
||||
// 去重按 (action, match):冲突元数据不同不影响运行时等价。
|
||||
// 去重按 (action, match):来源不影响运行时等价。
|
||||
let token = "\(rule.action.rawValue)|\(String(describing: rule.match))"
|
||||
guard seen.insert(token).inserted else { continue }
|
||||
ordered.append(rule)
|
||||
@@ -50,19 +50,19 @@ enum RuleCNabsorption {
|
||||
struct Result {
|
||||
/// 吸收后仍生效的规则。
|
||||
let rules: [ProxyRule]
|
||||
/// 被吸收的规则(conflict.absorbedBy 指向 `.cn` 后缀)。
|
||||
let absorbed: [ProxyRule]
|
||||
/// 转换期间删除的同动作 .cn 条目数。
|
||||
let absorbedCount: Int
|
||||
}
|
||||
|
||||
static func absorb(rules: [ProxyRule]) -> Result {
|
||||
let cnSuffix = RuleMatch.domainSuffix("cn")
|
||||
guard rules.contains(where: { $0.match == cnSuffix }) else {
|
||||
return Result(rules: rules, absorbed: [])
|
||||
return Result(rules: rules, absorbedCount: 0)
|
||||
}
|
||||
// 取 `.cn` 后缀规则的动作集合;只有同动作条目才被吸收。
|
||||
let cnActions = Set(rules.filter { $0.match == cnSuffix }.map(\.action))
|
||||
var kept: [ProxyRule] = []
|
||||
var absorbed: [ProxyRule] = []
|
||||
var absorbedCount = 0
|
||||
for rule in rules {
|
||||
if rule.match == cnSuffix {
|
||||
kept.append(rule)
|
||||
@@ -72,16 +72,9 @@ enum RuleCNabsorption {
|
||||
kept.append(rule)
|
||||
continue
|
||||
}
|
||||
var conflict = rule.conflict
|
||||
conflict = RuleConflictMetadata(
|
||||
originalEntry: conflict.originalEntry.isEmpty
|
||||
? String(describing: rule.match) : conflict.originalEntry,
|
||||
absorbedBy: cnSuffix,
|
||||
notes: conflict.notes + ["absorbed-by-cn-suffix"])
|
||||
absorbed.append(
|
||||
ProxyRule(action: rule.action, match: rule.match, source: rule.source, conflict: conflict))
|
||||
absorbedCount += 1
|
||||
}
|
||||
return Result(rules: kept, absorbed: absorbed)
|
||||
return Result(rules: kept, absorbedCount: absorbedCount)
|
||||
}
|
||||
|
||||
private static func isCoveredCNDomain(_ match: RuleMatch) -> Bool {
|
||||
|
||||
@@ -6,7 +6,7 @@ import Foundation
|
||||
/// 转换器版本、许可证与归属。更新失败保留上一份有效快照。
|
||||
struct RuleSnapshotMetadata: Codable, Equatable, Sendable {
|
||||
/// 转换器版本;快照格式或转换语义变化时递增。
|
||||
static let currentConverterVersion = "1.0.0"
|
||||
static let currentConverterVersion = "2.0.0"
|
||||
|
||||
let source: RuleSourceIdentity
|
||||
/// 上游仓库/产物标识(含固定 commit 或 release tag)。
|
||||
@@ -42,7 +42,7 @@ struct RuleSnapshotMetadata: Codable, Equatable, Sendable {
|
||||
|
||||
// MARK: - 转换损失报告
|
||||
|
||||
/// 转换损失报告:已转换、已吸收、按类跳过与拒绝的计数和原因。
|
||||
/// 转换损失报告:已转换、已吸收、按类跳过与拒绝的分类计数。
|
||||
struct RuleConversionLossReport: Codable, Equatable, Sendable {
|
||||
/// 成功进入规则模型的条目数。
|
||||
var convertedCount: Int
|
||||
@@ -52,21 +52,17 @@ struct RuleConversionLossReport: Codable, Equatable, Sendable {
|
||||
var skipped: [String: Int]
|
||||
/// 拒绝原因 → 计数。
|
||||
var rejected: [String: Int]
|
||||
/// 逐类说明(审查用)。
|
||||
var notes: [String]
|
||||
|
||||
init(
|
||||
convertedCount: Int = 0,
|
||||
absorbedCount: Int = 0,
|
||||
skipped: [String: Int] = [:],
|
||||
rejected: [String: Int] = [:],
|
||||
notes: [String] = []
|
||||
rejected: [String: Int] = [:]
|
||||
) {
|
||||
self.convertedCount = convertedCount
|
||||
self.absorbedCount = absorbedCount
|
||||
self.skipped = skipped
|
||||
self.rejected = rejected
|
||||
self.notes = notes
|
||||
}
|
||||
|
||||
mutating func incrementSkipped(_ category: String, by count: Int = 1) {
|
||||
@@ -80,28 +76,24 @@ struct RuleConversionLossReport: Codable, Equatable, Sendable {
|
||||
|
||||
// MARK: - 规则快照
|
||||
|
||||
/// 规范化规则快照:元数据 + 生效规则 + 被吸收条目 + 损失报告。
|
||||
/// 规范化规则快照:元数据 + 生效规则 + 纯计数损失报告。
|
||||
struct RuleSnapshot: Codable, Equatable, Sendable {
|
||||
/// 快照 schema 版本;加载时不匹配则失败。
|
||||
static let currentSchemaVersion = 1
|
||||
static let currentSchemaVersion = 2
|
||||
|
||||
let schemaVersion: Int
|
||||
let metadata: RuleSnapshotMetadata
|
||||
let rules: [ProxyRule]
|
||||
/// 被吸收的条目(保留审计,不进入运行时规则)。
|
||||
let absorbed: [ProxyRule]
|
||||
let lossReport: RuleConversionLossReport
|
||||
|
||||
init(
|
||||
metadata: RuleSnapshotMetadata,
|
||||
rules: [ProxyRule],
|
||||
absorbed: [ProxyRule] = [],
|
||||
lossReport: RuleConversionLossReport = RuleConversionLossReport()
|
||||
) {
|
||||
self.schemaVersion = Self.currentSchemaVersion
|
||||
self.metadata = metadata
|
||||
self.rules = rules
|
||||
self.absorbed = absorbed
|
||||
self.lossReport = lossReport
|
||||
}
|
||||
|
||||
@@ -112,6 +104,7 @@ struct RuleSnapshot: Codable, Equatable, Sendable {
|
||||
|
||||
enum RuleSnapshotError: Error, Equatable, Sendable {
|
||||
case missing
|
||||
case abnormalRuleCount(found: Int, minimum: Int, maximum: Int)
|
||||
case corrupt(detail: String)
|
||||
case schemaVersionMismatch(found: Int, expected: Int)
|
||||
case converterVersionMismatch(found: String, expected: String)
|
||||
@@ -147,12 +140,17 @@ struct RuleSnapshotStore {
|
||||
found: snapshot.metadata.converterVersion,
|
||||
expected: RuleSnapshotMetadata.currentConverterVersion)
|
||||
}
|
||||
let maximum = snapshot.metadata.source.kind == .geolocationCN ? 200_000 : 50_000
|
||||
guard (100...maximum).contains(snapshot.rules.count) else {
|
||||
throw RuleSnapshotError.abnormalRuleCount(
|
||||
found: snapshot.rules.count, minimum: 100, maximum: maximum)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func save(_ snapshot: RuleSnapshot) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(snapshot)
|
||||
try AtomicFileWriter.write(data, to: fileURL)
|
||||
@@ -166,7 +164,7 @@ struct RuleSnapshotStore {
|
||||
|
||||
static func encode(_ snapshot: RuleSnapshot) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return try encoder.encode(snapshot)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Browsing includes preserved expressible candidates without changing runtime sources.
|
||||
/// Browsing includes all shipped rules and saved custom entries without changing runtime sources.
|
||||
struct RulesCollection: Sendable {
|
||||
let version: String
|
||||
let rows: [RulesRow]
|
||||
@@ -83,10 +83,9 @@ private struct RulesCollectionInput: Sendable {
|
||||
case .gfwlist: expected = .gfwlist
|
||||
case .custom, .fixed: expected = .custom
|
||||
}
|
||||
guard snapshot.metadata.source.kind == expected,
|
||||
(snapshot.rules + snapshot.absorbed).allSatisfy({ $0.source.kind == expected })
|
||||
guard snapshot.metadata.source.kind == expected
|
||||
else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") }
|
||||
let candidates = snapshot.rules + snapshot.absorbed
|
||||
let candidates = snapshot.rules
|
||||
entries += candidates.map { RulesCandidate(rule: $0, source: source, customID: nil) }
|
||||
sources.append(
|
||||
RulesSourceSnapshot(
|
||||
@@ -105,11 +104,10 @@ private struct RulesCollectionInput: Sendable {
|
||||
}
|
||||
|
||||
private mutating func appendFixedPolicy() {
|
||||
let fixedSource = RuleSourceIdentity(kind: .custom, upstreamVersion: "fixed", label: "fixed")
|
||||
let fixedMatches = RuleCoverage.fixedLocalMatches
|
||||
entries += fixedMatches.map {
|
||||
RulesCandidate(
|
||||
rule: ProxyRule(action: .direct, match: $0, source: fixedSource), source: .fixed,
|
||||
rule: ProxyRule(action: .direct, match: $0), source: .fixed,
|
||||
customID: nil)
|
||||
}
|
||||
sources.append(
|
||||
|
||||
@@ -85,7 +85,11 @@ Scripts/packaging-gate.sh \
|
||||
|
||||
- **geolocation-cn**(issue #63):人工复验上游后运行 `Scripts/update-geolocation-cn.sh`,从固定版 [Loyalsoldier/domain-list-custom](https://github.com/Loyalsoldier/domain-list-custom) `geosite.dat` 解析 typed 条目并生成 `Vendor/rules/geolocation-cn/snapshot.json`。
|
||||
- **china-ipv4**(issue #64):运行 `Scripts/update-china-ipv4.sh`,从固定版 [gaoyifan/china-operator-ip](https://github.com/gaoyifan/china-operator-ip) `china.txt` 规范化并去重 IPv4 CIDR,生成 `Vendor/rules/china-ipv4/snapshot.json`。
|
||||
- **gfwlist**(issue #65):运行 `Scripts/update-gfwlist.sh`,从固定版 [gfwlist/gfwlist](https://github.com/gfwlist/gfwlist) `gfwlist.txt` 解码官方 Base64 AutoProxy 列表并生成 `Vendor/rules/gfwlist/snapshot.json`。只转换可按目标域名无损表达的 `||host` / `||host^` 规则;URL 路径、协议条件、通配符、正则、单标签前缀与 IP 字面量域名规则逐类计入损失报告,绝不扩大为整域名。被更宽代理规则遮蔽的 `@@` 例外不写入无效 ACL 项(sslocal 域名匹配 proxy_list 优先于 bypass_list),保留代理规则并逐项报告。
|
||||
- **gfwlist**(issue #65):运行 `Scripts/update-gfwlist.sh`,从固定版 [gfwlist/gfwlist](https://github.com/gfwlist/gfwlist) `gfwlist.txt` 解码官方 Base64 AutoProxy 列表并生成 `Vendor/rules/gfwlist/snapshot.json`。只转换可按目标域名无损表达的 `||host` / `||host^` 规则;URL 路径、协议条件、通配符、正则、单标签前缀与 IP 字面量域名规则逐类计入损失报告,绝不扩大为整域名。被更宽代理规则遮蔽的 `@@` 例外不写入无效 ACL 项(sslocal 域名匹配 proxy_list 优先于 bypass_list),保留代理规则,遮蔽数只计入 `absorbedCount`;逐条取证输出到维护脚本 stdout。
|
||||
|
||||
快照使用 schema 2 / converter 2.0.0 的紧凑 JSON:每条规则只有 `action` 和 `match`,来源只在 `metadata.source`,转换报告只保留计数。被吸收的 `.cn` 条目与被遮蔽的 GFWList 例外不随快照分发,禁用宽规则后不再恢复;详见 [ADR-0024](../docs/adr/0024-snapshot-drops-absorbed-and-forensics.md)。旧版本快照拒载。
|
||||
|
||||
转换脚本的离线回归检查:`PYTHONDONTWRITEBYTECODE=1 python3 Scripts/test-rule-snapshot-converters.py`。快照完整性检查:`Scripts/verify-rule-snapshots.sh`。
|
||||
|
||||
更新失败保留上一份有效快照。geolocation-cn 转换会因未知语法/损坏输入失败;china-ipv4 转换还会因异常格式(拒绝率过高)、全部失效或相对上一份快照的异常规模变化失败;gfwlist 转换因未知语法、损坏 Base64、异常规模或相对上一份快照的异常规模变化失败。普通构建由 `Scripts/verify-rule-snapshots.sh` 离线校验快照存在、摘要匹配且 schema/转换器版本一致,缺失或损坏即构建失败。分发物必须携带各自的 `NOTICE`(许可证与归属)。
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONVERTER_VERSION = "1.0.0"
|
||||
SCHEMA_VERSION = 1
|
||||
CONVERTER_VERSION = "2.0.0"
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
MINIMUM_RULE_COUNT = 100
|
||||
MAXIMUM_RULE_COUNT = 50_000
|
||||
@@ -40,6 +40,7 @@ def parse_lines(text: str) -> tuple[list[dict], int, int]:
|
||||
try:
|
||||
network = ipaddress.IPv4Network(line, strict=False)
|
||||
except ValueError:
|
||||
print(f"invalidCIDR: {line}")
|
||||
rejected += 1
|
||||
continue
|
||||
normalized = f"{network.network_address}/{network.prefixlen}"
|
||||
@@ -51,12 +52,6 @@ def parse_lines(text: str) -> tuple[list[dict], int, int]:
|
||||
{
|
||||
"action": "direct",
|
||||
"match": {"kind": "ipv4CIDR", "value": normalized},
|
||||
"source": {}, # filled by caller
|
||||
"conflict": {
|
||||
"originalEntry": line,
|
||||
"absorbedBy": None,
|
||||
"notes": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
return rules, duplicates, rejected
|
||||
@@ -92,15 +87,11 @@ def build_snapshot(
|
||||
f"(allowed {lower}..{upper})"
|
||||
)
|
||||
|
||||
for rule in rules:
|
||||
rule["source"] = source
|
||||
|
||||
report = {
|
||||
"convertedCount": rule_count,
|
||||
"absorbedCount": 0,
|
||||
"skipped": {"duplicate": duplicates} if duplicates else {},
|
||||
"rejected": {"invalidCIDR": rejected} if rejected else {},
|
||||
"notes": ["china-ipv4-direct-candidates"],
|
||||
}
|
||||
return {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
@@ -117,7 +108,6 @@ def build_snapshot(
|
||||
"attribution": attribution,
|
||||
},
|
||||
"rules": rules,
|
||||
"absorbed": [],
|
||||
"lossReport": report,
|
||||
}
|
||||
|
||||
@@ -159,7 +149,7 @@ def main() -> int:
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = args.out.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.write_text(json.dumps(snapshot, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.replace(args.out)
|
||||
loss = snapshot["lossReport"]
|
||||
print(
|
||||
|
||||
@@ -17,8 +17,8 @@ import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONVERTER_VERSION = "1.0.0"
|
||||
SCHEMA_VERSION = 1
|
||||
CONVERTER_VERSION = "2.0.0"
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
# v2ray geosite Domain.Type
|
||||
TYPE_PLAIN = 0
|
||||
@@ -190,15 +190,15 @@ def domain_exact(value: str) -> str | None:
|
||||
return v
|
||||
|
||||
|
||||
def convert(entries: list[dict], source: dict) -> dict:
|
||||
def convert(entries: list[dict]) -> dict:
|
||||
report = {
|
||||
"convertedCount": 0,
|
||||
"absorbedCount": 0,
|
||||
"skipped": {},
|
||||
"rejected": {},
|
||||
"notes": [],
|
||||
}
|
||||
rules: list[dict] = []
|
||||
originals: dict[tuple[str, str], str] = {}
|
||||
|
||||
def bump(bucket: str, key: str, n: int = 1) -> None:
|
||||
target = report[bucket]
|
||||
@@ -207,11 +207,12 @@ def convert(entries: list[dict], source: dict) -> dict:
|
||||
for entry in entries:
|
||||
etype = entry["type"]
|
||||
value = entry["value"]
|
||||
name = TYPE_NAMES.get(etype, f"unknown-{etype}")
|
||||
if etype == TYPE_PLAIN:
|
||||
print(f"keyword: {value}")
|
||||
bump("skipped", "keyword")
|
||||
continue
|
||||
if etype == TYPE_REGEX:
|
||||
print(f"regexp: {value}")
|
||||
bump("skipped", "regexp")
|
||||
continue
|
||||
if etype == TYPE_DOMAIN:
|
||||
@@ -226,18 +227,14 @@ def convert(entries: list[dict], source: dict) -> dict:
|
||||
bump("skipped", "unknownType")
|
||||
continue
|
||||
if normalized is None:
|
||||
print(f"invalidDomain: {value}")
|
||||
bump("rejected", "invalidDomain")
|
||||
continue
|
||||
originals.setdefault((kind, normalized), original)
|
||||
rules.append(
|
||||
{
|
||||
"action": "direct",
|
||||
"match": {"kind": kind, "value": normalized},
|
||||
"source": source,
|
||||
"conflict": {
|
||||
"originalEntry": original,
|
||||
"absorbedBy": None,
|
||||
"notes": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -261,17 +258,11 @@ def convert(entries: list[dict], source: dict) -> dict:
|
||||
{
|
||||
"action": "direct",
|
||||
"match": {"kind": "domainSuffix", "value": "cn"},
|
||||
"source": source,
|
||||
"conflict": {
|
||||
"originalEntry": "synthesized:.cn-suffix",
|
||||
"absorbedBy": None,
|
||||
"notes": ["synthesized-cn-suffix"],
|
||||
},
|
||||
},
|
||||
)
|
||||
report["notes"].append("synthesized-cn-suffix")
|
||||
print("synthesized-cn-suffix")
|
||||
|
||||
absorbed: list[dict] = []
|
||||
absorbed_count = 0
|
||||
kept: list[dict] = []
|
||||
for rule in rules:
|
||||
kind = rule["match"]["kind"]
|
||||
@@ -283,23 +274,16 @@ def convert(entries: list[dict], source: dict) -> dict:
|
||||
kind == "domainExact" and value.endswith(".cn")
|
||||
)
|
||||
if covered and rule["action"] == "direct":
|
||||
rule = dict(rule)
|
||||
rule["conflict"] = {
|
||||
"originalEntry": rule["conflict"]["originalEntry"]
|
||||
or f"{kind}:{value}",
|
||||
"absorbedBy": {"kind": "domainSuffix", "value": "cn"},
|
||||
"notes": list(rule["conflict"].get("notes") or []) + ["absorbed-by-cn-suffix"],
|
||||
}
|
||||
absorbed.append(rule)
|
||||
print(f"absorbed-by-cn-suffix: {originals[(kind, value)]}")
|
||||
absorbed_count += 1
|
||||
else:
|
||||
kept.append(rule)
|
||||
|
||||
report["convertedCount"] = len(kept)
|
||||
report["absorbedCount"] = len(absorbed)
|
||||
report["absorbedCount"] = absorbed_count
|
||||
return {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"rules": kept,
|
||||
"absorbed": absorbed,
|
||||
"lossReport": report,
|
||||
}
|
||||
|
||||
@@ -335,7 +319,7 @@ def build_snapshot(
|
||||
"license": license_name,
|
||||
"attribution": attribution,
|
||||
}
|
||||
snapshot = convert(match["domains"], source)
|
||||
snapshot = convert(match["domains"])
|
||||
snapshot["metadata"] = metadata
|
||||
return snapshot
|
||||
|
||||
@@ -367,7 +351,7 @@ def main() -> int:
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = args.out.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.write_text(json.dumps(snapshot, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.replace(args.out)
|
||||
loss = snapshot["lossReport"]
|
||||
print(
|
||||
|
||||
@@ -22,8 +22,8 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONVERTER_VERSION = "1.0.0"
|
||||
SCHEMA_VERSION = 1
|
||||
CONVERTER_VERSION = "2.0.0"
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
MINIMUM_RULE_COUNT = 100
|
||||
MAXIMUM_RULE_COUNT = 50_000
|
||||
@@ -136,32 +136,24 @@ def split_shadowed(
|
||||
if blocker is None:
|
||||
kept_exceptions.append(exception)
|
||||
else:
|
||||
marked = dict(exception)
|
||||
marked["conflict"] = {
|
||||
"originalEntry": exception["conflict"]["originalEntry"],
|
||||
"absorbedBy": blocker["match"],
|
||||
"notes": list(exception["conflict"]["notes"]) + ["shadowed-by-broader-proxy"],
|
||||
}
|
||||
shadowed.append((marked, blocker))
|
||||
shadowed.append((exception, blocker))
|
||||
return proxy_rules, kept_exceptions, shadowed
|
||||
|
||||
|
||||
def make_rule(action: str, match: dict, original: str, source: dict) -> dict:
|
||||
def make_rule(action: str, match: dict) -> dict:
|
||||
return {
|
||||
"action": action,
|
||||
"match": match,
|
||||
"source": source,
|
||||
"conflict": {"originalEntry": original, "absorbedBy": None, "notes": []},
|
||||
}
|
||||
|
||||
|
||||
def convert_document(document: str, source: dict) -> tuple[list[dict], list[dict], dict]:
|
||||
"""Return (rules, absorbed, lossReport)."""
|
||||
def convert_document(document: str) -> tuple[list[dict], dict]:
|
||||
"""Return (rules, lossReport)."""
|
||||
skipped: dict[str, int] = {}
|
||||
notes: list[str] = []
|
||||
proxy_rules: list[dict] = []
|
||||
exception_rules: list[dict] = []
|
||||
saw_code_line = False
|
||||
originals: dict[tuple[str, str], str] = {}
|
||||
|
||||
def bump(category: str) -> None:
|
||||
skipped[category] = skipped.get(category, 0) + 1
|
||||
@@ -176,23 +168,21 @@ def convert_document(document: str, source: dict) -> tuple[list[dict], list[dict
|
||||
elif kind == "header":
|
||||
bump("header")
|
||||
elif kind == "domainProxy":
|
||||
originals.setdefault(("proxy", entry["host"]), entry["original"])
|
||||
saw_code_line = True
|
||||
proxy_rules.append(
|
||||
make_rule(
|
||||
"proxy",
|
||||
{"kind": "domainSuffix", "value": entry["host"]},
|
||||
entry["original"],
|
||||
source,
|
||||
)
|
||||
)
|
||||
elif kind == "domainException":
|
||||
originals.setdefault(("direct", entry["host"]), entry["original"])
|
||||
saw_code_line = True
|
||||
exception_rules.append(
|
||||
make_rule(
|
||||
"direct",
|
||||
{"kind": "domainSuffix", "value": entry["host"]},
|
||||
entry["original"],
|
||||
source,
|
||||
)
|
||||
)
|
||||
elif kind in {
|
||||
@@ -207,7 +197,7 @@ def convert_document(document: str, source: dict) -> tuple[list[dict], list[dict
|
||||
}:
|
||||
saw_code_line = True
|
||||
bump(kind)
|
||||
notes.append(f"unexpressible-{kind}: {entry['original']}")
|
||||
print(f"unexpressible-{kind}: {entry['original']}")
|
||||
else:
|
||||
raise UnknownSyntax(entry.get("original", raw_line))
|
||||
|
||||
@@ -216,12 +206,11 @@ def convert_document(document: str, source: dict) -> tuple[list[dict], list[dict
|
||||
|
||||
kept_proxy, kept_exceptions, shadowed = split_shadowed(proxy_rules, exception_rules)
|
||||
if shadowed:
|
||||
skipped["shadowedException"] = len(shadowed)
|
||||
for exception, blocker in shadowed:
|
||||
notes.append(
|
||||
print(
|
||||
"shadowed-exception: "
|
||||
f"{exception['conflict']['originalEntry']} "
|
||||
f"shadowed-by {blocker['conflict']['originalEntry']}"
|
||||
f"{originals[('direct', exception['match']['value'])]} "
|
||||
f"shadowed-by {originals[('proxy', blocker['match']['value'])]}"
|
||||
)
|
||||
|
||||
rules = kept_proxy + kept_exceptions
|
||||
@@ -241,9 +230,8 @@ def convert_document(document: str, source: dict) -> tuple[list[dict], list[dict
|
||||
"absorbedCount": len(shadowed),
|
||||
"skipped": skipped,
|
||||
"rejected": {},
|
||||
"notes": notes,
|
||||
}
|
||||
return rules, [item for item, _ in shadowed], report
|
||||
return rules, report
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
@@ -254,7 +242,7 @@ def build_snapshot(
|
||||
attribution: str,
|
||||
previous_rule_count: int | None,
|
||||
) -> dict:
|
||||
rules, absorbed, report = convert_document(document, source)
|
||||
rules, report = convert_document(document)
|
||||
rule_count = len(rules)
|
||||
if rule_count < MINIMUM_RULE_COUNT:
|
||||
raise SystemExit(f"abnormal rule count {rule_count} (min {MINIMUM_RULE_COUNT})")
|
||||
@@ -284,7 +272,6 @@ def build_snapshot(
|
||||
"attribution": attribution,
|
||||
},
|
||||
"rules": rules,
|
||||
"absorbed": absorbed,
|
||||
"lossReport": report,
|
||||
}
|
||||
|
||||
@@ -342,13 +329,13 @@ def main() -> int:
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = args.out.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.write_text(json.dumps(snapshot, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.replace(args.out)
|
||||
loss = snapshot["lossReport"]
|
||||
print(
|
||||
f"wrote {args.out}: {loss['convertedCount']} rules, "
|
||||
f"skipped={loss['skipped']}, "
|
||||
f"shadowed={loss['skipped'].get('shadowedException', 0)}"
|
||||
f"shadowed={loss['absorbedCount']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavior checks at the maintainer conversion boundary (no network)."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
def converter(name):
|
||||
path = Path(__file__).with_name(f"convert-{name}.py")
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SnapshotConverterTests(unittest.TestCase):
|
||||
def assert_slim(self, snapshot):
|
||||
self.assertEqual(snapshot["schemaVersion"], 2)
|
||||
self.assertNotIn("absorbed", snapshot)
|
||||
for rule in snapshot["rules"]:
|
||||
self.assertEqual(set(rule), {"action", "match"})
|
||||
report = snapshot["lossReport"]
|
||||
self.assertEqual(set(report), {"convertedCount", "absorbedCount", "skipped", "rejected"})
|
||||
self.assertEqual(report["convertedCount"], len(snapshot["rules"]))
|
||||
for category in ("skipped", "rejected"):
|
||||
self.assertTrue(all(type(count) is int and count >= 0 for count in report[category].values()))
|
||||
self.assertNotIn("shadowedException", report["skipped"])
|
||||
|
||||
def test_cn_absorption_drops_narrow_entries_and_reports_only_totals(self):
|
||||
geo = converter("geolocation-cn")
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
snapshot = geo.convert([
|
||||
{"type": geo.TYPE_DOMAIN, "value": "foo.cn"},
|
||||
{"type": geo.TYPE_FULL, "value": "exact.cn"},
|
||||
{"type": geo.TYPE_DOMAIN, "value": "example.com"},
|
||||
{"type": geo.TYPE_REGEX, "value": "^example"},
|
||||
])
|
||||
self.assert_slim(snapshot)
|
||||
self.assertEqual([rule["match"]["value"] for rule in snapshot["rules"]], ["cn", "example.com"])
|
||||
self.assertEqual(snapshot["lossReport"]["absorbedCount"], 2)
|
||||
self.assertEqual(snapshot["lossReport"]["skipped"], {"regexp": 1})
|
||||
self.assertIn("synthesized-cn-suffix", output.getvalue())
|
||||
self.assertIn("foo.cn", output.getvalue())
|
||||
self.assertNotIn("foo.cn", json.dumps(snapshot))
|
||||
|
||||
def test_gfw_shadowed_exception_counts_once_and_keeps_raw_evidence_on_stdout(self):
|
||||
gfw = converter("gfwlist")
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
rules, report = gfw.convert_document(
|
||||
"||Example.COM^\n@@||Safe.Example.COM^\n@@||other.example\n|https://path.example/\n")
|
||||
snapshot = {"schemaVersion": gfw.SCHEMA_VERSION, "rules": rules, "lossReport": report}
|
||||
self.assert_slim(snapshot)
|
||||
self.assertEqual(report["absorbedCount"], 1)
|
||||
self.assertEqual(report["skipped"], {"urlPrefix": 1})
|
||||
self.assertEqual([rule["match"]["value"] for rule in rules], ["example.com", "other.example"])
|
||||
self.assertIn("@@||Safe.Example.COM^ shadowed-by ||Example.COM^", output.getvalue())
|
||||
self.assertNotIn("Safe.Example.COM", json.dumps(snapshot))
|
||||
|
||||
def test_china_ip_build_retains_metadata_normalization_and_duplicate_counts(self):
|
||||
china = converter("china-ipv4")
|
||||
document = "\n".join(f"1.0.{index}.1/24" for index in range(100)) + "\n1.0.0.2/24\n"
|
||||
source = {"kind": "china-ipv4", "upstreamVersion": "fixed", "label": "China"}
|
||||
snapshot = china.build_snapshot(document, source, "fixed", "MIT", "author", None)
|
||||
self.assert_slim(snapshot)
|
||||
self.assertEqual(snapshot["metadata"]["source"], source)
|
||||
self.assertEqual(snapshot["metadata"]["converterVersion"], "2.0.0")
|
||||
self.assertEqual(snapshot["rules"][0]["match"]["value"], "1.0.0.0/24")
|
||||
self.assertEqual(snapshot["lossReport"]["skipped"], {"duplicate": 1})
|
||||
|
||||
def test_shipped_artifacts_use_compact_schema_and_preserve_pinned_rule_counts(self):
|
||||
for name, count, absorbed in [("geolocation-cn", 4241, 1138), ("china-ipv4", 6206, 0), ("gfwlist", 4046, 31)]:
|
||||
with self.subTest(source=name):
|
||||
data = (Path(__file__).parent.parent / "Vendor" / "rules" / name / "snapshot.json").read_bytes()
|
||||
self.assertEqual(data.count(b"\n"), 1)
|
||||
snapshot = json.loads(data)
|
||||
self.assert_slim(snapshot)
|
||||
self.assertEqual(len(snapshot["rules"]), count)
|
||||
self.assertEqual(snapshot["lossReport"]["absorbedCount"], absorbed)
|
||||
self.assertEqual(snapshot["metadata"]["source"]["kind"], name)
|
||||
self.assertEqual(snapshot["metadata"]["converterVersion"], "2.0.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -117,7 +117,7 @@ for key in ("source", "upstreamReference", "inputDigest", "fetchedAt", "converte
|
||||
if not data.get("rules"):
|
||||
raise SystemExit("snapshot has no rules")
|
||||
print(f"snapshot ok: {len(data['rules'])} rules, "
|
||||
f"shadowed={data.get('lossReport', {}).get('skipped', {}).get('shadowedException', 0)}")
|
||||
f"shadowed={data.get('lossReport', {}).get('absorbedCount', 0)}")
|
||||
PY
|
||||
|
||||
cp "${TMP}/snapshot.json" "${SNAPSHOT}"
|
||||
|
||||
@@ -38,17 +38,17 @@ try:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SystemExit(f"snapshot is not valid JSON: {exc}")
|
||||
|
||||
if snapshot.get("schemaVersion") != 1:
|
||||
raise SystemExit(f"schemaVersion mismatch: {snapshot.get('schemaVersion')!r} != 1")
|
||||
if snapshot.get("schemaVersion") != 2:
|
||||
raise SystemExit(f"schemaVersion mismatch: {snapshot.get('schemaVersion')!r} != 2")
|
||||
|
||||
meta = snapshot.get("metadata") or {}
|
||||
for key in ("source", "upstreamReference", "inputDigest", "fetchedAt", "converterVersion", "license", "attribution"):
|
||||
if key not in meta:
|
||||
raise SystemExit(f"snapshot metadata missing {key!r}")
|
||||
|
||||
if meta.get("converterVersion") != "1.0.0":
|
||||
if meta.get("converterVersion") != "2.0.0":
|
||||
raise SystemExit(
|
||||
f"converterVersion mismatch: {meta.get('converterVersion')!r} != '1.0.0' "
|
||||
f"converterVersion mismatch: {meta.get('converterVersion')!r} != '2.0.0' "
|
||||
"(re-run the explicit update or bump both sides together)"
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ final class BuiltinRuleCatalogTests: XCTestCase {
|
||||
rules.contains { $0.match == .domainSuffix("cn") },
|
||||
"快照应含 .cn 后缀直连规则")
|
||||
|
||||
// 完整候选保留可恢复的独立 .cn 域名条目。
|
||||
// 转换期已删除被 .cn 吸收的域名条目。
|
||||
let independentCN = rules.filter {
|
||||
switch $0.match {
|
||||
case .domainSuffix(let value): return value.hasSuffix(".cn") && value != "cn"
|
||||
@@ -89,7 +89,7 @@ final class BuiltinRuleCatalogTests: XCTestCase {
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
XCTAssertFalse(independentCN.isEmpty, "候选保留被吸收的规则以便禁用宽规则后恢复")
|
||||
XCTAssertTrue(independentCN.isEmpty, "快照不保留被吸收的规则")
|
||||
}
|
||||
|
||||
/// china-ipv4 快照(issue #64):从 bundle 或源码树加载,直连候选为 IPv4 CIDR。
|
||||
@@ -151,10 +151,8 @@ final class BuiltinRuleCatalogTests: XCTestCase {
|
||||
}, "gfwlist 快照候选应为域名后缀")
|
||||
|
||||
// 损失报告保留来源、摘要、许可证/归属与转换损失(含遮蔽例外)。
|
||||
XCTAssertGreaterThanOrEqual(snapshot.lossReport.skipped["shadowedException"] ?? 0, 1)
|
||||
XCTAssertFalse(snapshot.lossReport.notes.isEmpty)
|
||||
XCTAssertEqual(snapshot.absorbed.count, snapshot.lossReport.skipped["shadowedException"] ?? 0)
|
||||
XCTAssertEqual(snapshot.lossReport.absorbedCount, snapshot.absorbed.count)
|
||||
XCTAssertGreaterThan(snapshot.lossReport.absorbedCount, 0)
|
||||
XCTAssertNil(snapshot.lossReport.skipped["shadowedException"])
|
||||
}
|
||||
|
||||
/// 合并投影(issue #64):域名 + CIDR 直连候选一起进入 ACL 编译输入。
|
||||
|
||||
@@ -53,7 +53,7 @@ final class ChinaIPv4ConverterTests: XCTestCase {
|
||||
}
|
||||
XCTAssertEqual(values, ["1.0.1.0/24", "1.0.2.0/23", "203.0.113.7/32"])
|
||||
XCTAssertTrue(snapshot.rules.allSatisfy { $0.action == .direct })
|
||||
XCTAssertEqual(snapshot.rules.allSatisfy { $0.source.kind == .chinaIPv4 }, true)
|
||||
XCTAssertEqual(snapshot.metadata.source.kind, .chinaIPv4)
|
||||
XCTAssertEqual(snapshot.lossReport.convertedCount, 3)
|
||||
XCTAssertEqual(snapshot.lossReport.rejected["invalidCIDR"] ?? 0, 0)
|
||||
}
|
||||
@@ -179,12 +179,11 @@ final class ChinaIPv4ConverterTests: XCTestCase {
|
||||
XCTAssertEqual(
|
||||
snapshot.metadata.inputDigest,
|
||||
ProxyACLDocument.digest(Data("1.0.1.0/24\n".utf8)))
|
||||
XCTAssertEqual(snapshot.lossReport.notes, ["china-ipv4-direct-candidates"])
|
||||
XCTAssertTrue(snapshot.absorbed.isEmpty)
|
||||
XCTAssertEqual(snapshot.lossReport.absorbedCount, 0)
|
||||
}
|
||||
|
||||
func testSnapshotRoundTripsThroughStore() throws {
|
||||
let snapshot = try convert("1.0.1.0/24\n1.0.2.0/23\n")
|
||||
let snapshot = try convert((0..<100).map { "1.0.\($0).0/24" }.joined(separator: "\n"))
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ssxng-china-ipv4-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
@@ -10,8 +10,7 @@ final class CustomRuleDeletionWorkflowTests: XCTestCase {
|
||||
let custom = CustomRule(action: .proxy, match: .domainSuffix("merged.example"))
|
||||
let other = CustomRule(action: .direct, match: .domainExact("other.example"))
|
||||
let orphan = RuleIdentity(action: .proxy, match: .domainExact("orphan.example"))
|
||||
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "GFWList")
|
||||
let builtin = ProxyRule(action: custom.action, match: custom.match, source: source)
|
||||
let builtin = ProxyRule(action: custom.action, match: custom.match)
|
||||
var saved = CustomRuleDocument(
|
||||
rules: [custom, other], disabledIdentities: [custom.identity, orphan])
|
||||
var commits = 0
|
||||
|
||||
@@ -32,8 +32,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase {
|
||||
let original = CustomRule(
|
||||
action: .proxy, match: .domainSuffix("old.example"),
|
||||
source: RuleSourceIdentity(kind: .custom, upstreamVersion: "imported", label: "User import"))
|
||||
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "GFWList")
|
||||
let builtin = ProxyRule(action: .proxy, match: original.match, source: source)
|
||||
let builtin = ProxyRule(action: .proxy, match: original.match)
|
||||
var saved = CustomRuleDocument(rules: [original], disabledIdentities: [original.identity])
|
||||
let workflow = RulesWorkflow(
|
||||
loadDocument: { saved },
|
||||
@@ -128,8 +127,7 @@ final class CustomRuleEditingWorkflowTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testEquivalentBuiltinAndDisabledOrphanCanBeAddedWithoutEnabling() async throws {
|
||||
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "GFWList")
|
||||
let builtin = ProxyRule(action: .proxy, match: .domainSuffix("merge.example"), source: source)
|
||||
let builtin = ProxyRule(action: .proxy, match: .domainSuffix("merge.example"))
|
||||
let orphan = RuleIdentity(action: .proxy, match: .domainExact("orphan.example"))
|
||||
let workflow = RulesWorkflow(
|
||||
loadDocument: {
|
||||
|
||||
@@ -6,20 +6,16 @@ import XCTest
|
||||
/// 自定义规则合并进 ACL(issue #66 AC3):两种规则子模式按各自内置来源与
|
||||
/// 自定义规则合并;全局和直连模式不加载自定义规则。
|
||||
final class CustomRuleMergeTests: XCTestCase {
|
||||
private let chinaSource = RuleSourceIdentity(
|
||||
kind: .geolocationCN, upstreamVersion: "v1", label: "geolocation-cn")
|
||||
private let gfwSource = RuleSourceIdentity(
|
||||
kind: .gfwlist, upstreamVersion: "v1", label: "GFWList")
|
||||
|
||||
private func custom(_ action: RuleAction, _ match: RuleMatch) -> CustomRule {
|
||||
CustomRule(action: action, match: match)
|
||||
}
|
||||
|
||||
func testACLIdentityDoesNotDependOnRuleOrSourceOrder() {
|
||||
let first = ProxyRule(action: .direct, match: .domainSuffix("z.example"), source: chinaSource)
|
||||
func testACLIdentityDoesNotDependOnRuleOrderOrDuplicates() {
|
||||
let first = ProxyRule(action: .direct, match: .domainSuffix("z.example"))
|
||||
let equivalent = ProxyRule(
|
||||
action: .direct, match: .domainSuffix("z.example"), source: gfwSource)
|
||||
let second = ProxyRule(action: .proxy, match: .domainExact("a.example"), source: gfwSource)
|
||||
action: .direct, match: .domainSuffix("z.example"))
|
||||
let second = ProxyRule(action: .proxy, match: .domainExact("a.example"))
|
||||
let url = URL(fileURLWithPath: "/tmp/rules-order.acl")
|
||||
for action in RuleDefaultAction.allCases {
|
||||
let expected = ProxyACLDocument.rule(at: url, defaultAction: action, rules: [first, second])
|
||||
@@ -35,9 +31,9 @@ final class CustomRuleMergeTests: XCTestCase {
|
||||
func testProxyWhenUnmatchedMergesChinaDirectAndCustomDirect() throws {
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"), source: chinaSource),
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn")),
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: chinaSource),
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24")),
|
||||
]
|
||||
let customRules = [
|
||||
custom(.direct, try RuleMatch(domainSuffix: "internal.example")),
|
||||
@@ -68,7 +64,7 @@ final class CustomRuleMergeTests: XCTestCase {
|
||||
func testDirectWhenUnmatchedMergesGFWListAndCustomRules() throws {
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"))
|
||||
]
|
||||
let customRules = [
|
||||
custom(.proxy, try RuleMatch(domainSuffix: "also-blocked.example")),
|
||||
@@ -95,7 +91,7 @@ final class CustomRuleMergeTests: XCTestCase {
|
||||
func testShadowedCustomDirectIsRetainedAndExplained() throws {
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"))
|
||||
]
|
||||
let customRules = [
|
||||
custom(.direct, try RuleMatch(domainExact: "sub.blocked.example"))
|
||||
@@ -120,11 +116,11 @@ final class CustomRuleMergeTests: XCTestCase {
|
||||
func testOneModeShadowedRuleIsValidInTheOtherMode() throws {
|
||||
let gfwBuiltIn = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"))
|
||||
]
|
||||
let chinaBuiltIn = [
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"), source: chinaSource)
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"))
|
||||
]
|
||||
let customRules = [
|
||||
custom(.direct, try RuleMatch(domainExact: "sub.blocked.example"))
|
||||
|
||||
@@ -6,12 +6,6 @@ import XCTest
|
||||
/// 自定义规则校验(issue #66 AC2):固定本地冲突、ACL 优先级说明与重复拒绝,
|
||||
/// 并返回可解释原因。
|
||||
final class CustomRuleValidatorTests: XCTestCase {
|
||||
private let customSource = RuleSourceIdentity(
|
||||
kind: .custom, upstreamVersion: "user", label: "自定义")
|
||||
private let gfwSource = RuleSourceIdentity(
|
||||
kind: .gfwlist, upstreamVersion: "v1", label: "GFWList")
|
||||
private let chinaSource = RuleSourceIdentity(
|
||||
kind: .geolocationCN, upstreamVersion: "v1", label: "geolocation-cn")
|
||||
|
||||
private func rule(
|
||||
_ action: RuleAction,
|
||||
@@ -135,7 +129,7 @@ final class CustomRuleValidatorTests: XCTestCase {
|
||||
func testDomainDirectShadowedByBroaderDomainProxyIsExplainedWhenProxySideWritten() throws {
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"))
|
||||
]
|
||||
let custom = [rule(.direct, try RuleMatch(domainExact: "sub.blocked.example"))]
|
||||
|
||||
@@ -151,7 +145,7 @@ final class CustomRuleValidatorTests: XCTestCase {
|
||||
// 「未匹配时代理」骨架不写 proxy_list,域名代理表为空,直连规则不会被遮蔽。
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"))
|
||||
]
|
||||
let custom = [rule(.direct, try RuleMatch(domainExact: "sub.blocked.example"))]
|
||||
|
||||
@@ -165,7 +159,7 @@ final class CustomRuleValidatorTests: XCTestCase {
|
||||
func testIPProxyShadowedByBroaderIPDirectIsExplainedWhenProxySideWritten() throws {
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: chinaSource)
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"))
|
||||
]
|
||||
let custom = [rule(.proxy, try RuleMatch(ipv4CIDR: "203.0.113.10/32"))]
|
||||
|
||||
@@ -181,7 +175,7 @@ final class CustomRuleValidatorTests: XCTestCase {
|
||||
// 「未匹配时代理」不写 proxy_list;IP 代理规则不参与 bypass 优先判定。
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: chinaSource)
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"))
|
||||
]
|
||||
let custom = [rule(.proxy, try RuleMatch(ipv4CIDR: "203.0.113.10/32"))]
|
||||
|
||||
@@ -196,7 +190,7 @@ final class CustomRuleValidatorTests: XCTestCase {
|
||||
// 域名 proxy_list 优先于 bypass_list:代理规则不会被直连遮蔽。
|
||||
let builtIn = [
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"), source: chinaSource)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
]
|
||||
let custom = [rule(.proxy, try RuleMatch(domainExact: "a.example.com"))]
|
||||
|
||||
|
||||
@@ -183,18 +183,9 @@ final class GFWListConverterTests: XCTestCase {
|
||||
// 遮蔽的例外不写入无效 ACL 项;更宽代理规则保留。
|
||||
XCTAssertEqual(Set(proxyHosts(snapshot)), ["example.com", "gstatic.com"])
|
||||
XCTAssertEqual(directHosts(snapshot), ["unrelated.example.org"])
|
||||
XCTAssertEqual(snapshot.lossReport.skipped["shadowedException"], 2)
|
||||
XCTAssertNil(snapshot.lossReport.skipped["shadowedException"])
|
||||
XCTAssertEqual(snapshot.lossReport.absorbedCount, 2, "遮蔽例外计入吸收/遮蔽条目数")
|
||||
let notes = snapshot.lossReport.notes.joined(separator: "\n")
|
||||
XCTAssertTrue(notes.contains("@@||fonts.gstatic.com"), "逐项报告被遮蔽的例外")
|
||||
XCTAssertTrue(notes.contains("@@||www.example.com"))
|
||||
XCTAssertTrue(notes.contains("||gstatic.com") || notes.contains("||example.com"))
|
||||
// 被遮蔽例外保留在 absorbed 供审计。
|
||||
XCTAssertEqual(snapshot.absorbed.count, 2)
|
||||
XCTAssertTrue(
|
||||
snapshot.absorbed.allSatisfy {
|
||||
$0.action == .direct && $0.conflict.notes.contains("shadowed-by-broader-proxy")
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func testEqualMatchExceptionIsShadowedByProxy() throws {
|
||||
@@ -205,7 +196,7 @@ final class GFWListConverterTests: XCTestCase {
|
||||
""")
|
||||
XCTAssertEqual(proxyHosts(snapshot), ["same.example"])
|
||||
XCTAssertTrue(directHosts(snapshot).isEmpty)
|
||||
XCTAssertEqual(snapshot.lossReport.skipped["shadowedException"], 1)
|
||||
XCTAssertEqual(snapshot.lossReport.absorbedCount, 1)
|
||||
}
|
||||
|
||||
func testNarrowerProxyDoesNotShadowBroaderException() throws {
|
||||
@@ -235,3 +226,26 @@ final class GFWListConverterTests: XCTestCase {
|
||||
XCTAssertEqual(snapshot.lossReport.skipped["regexp"], 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension GFWListConverterTests {
|
||||
func testDisablingBlockerDoesNotRestoreOmittedOfflineException() throws {
|
||||
let snapshot = try convert("||example.com\n@@||safe.example.com\n")
|
||||
let collection = RulesCollection.load(
|
||||
custom: { [] },
|
||||
builtin: { source in
|
||||
source == .gfwlist ? snapshot : rulesFixture(source)
|
||||
})
|
||||
let exception = RuleIdentity(action: .direct, match: .domainSuffix("safe.example.com"))
|
||||
XCTAssertFalse(collection.rows.contains { $0.identity == exception })
|
||||
XCTAssertEqual(
|
||||
try OfflineRuleMatcher.test(collection: collection, address: "safe.example.com").outcome,
|
||||
.proxy)
|
||||
let disabled = collection.replacingUserDocument(
|
||||
CustomRuleDocument(
|
||||
rules: [],
|
||||
disabledIdentities: [RuleIdentity(action: .proxy, match: .domainSuffix("example.com"))]))
|
||||
let result = try OfflineRuleMatcher.test(collection: disabled, address: "safe.example.com")
|
||||
XCTAssertEqual(result.outcome, .unmatched)
|
||||
XCTAssertTrue(result.deciding.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,17 +161,13 @@ final class GeolocationCNConverterTests: XCTestCase {
|
||||
XCTAssertFalse(matches.contains(.domainExact("exact.cn")))
|
||||
XCTAssertFalse(matches.contains(.domainSuffix("proxy.cn")))
|
||||
XCTAssertEqual(snapshot.lossReport.absorbedCount, 4)
|
||||
XCTAssertTrue(
|
||||
snapshot.absorbed.allSatisfy { $0.conflict.absorbedBy == .domainSuffix("cn") })
|
||||
XCTAssertTrue(
|
||||
snapshot.lossReport.notes.contains("synthesized-cn-suffix"))
|
||||
|
||||
}
|
||||
|
||||
func testDoesNotDuplicateCNSuffixWhenInputProvidesIt() throws {
|
||||
let snapshot = try convert("cn\nfoo.cn\nexample.com\n")
|
||||
let cnRules = snapshot.rules.filter { $0.match == .domainSuffix("cn") }
|
||||
XCTAssertEqual(cnRules.count, 1)
|
||||
XCTAssertFalse(snapshot.lossReport.notes.contains("synthesized-cn-suffix"))
|
||||
}
|
||||
|
||||
// MARK: - 去重与冲突
|
||||
@@ -247,8 +243,86 @@ final class GeolocationCNConverterTests: XCTestCase {
|
||||
|
||||
// MARK: - 快照存储
|
||||
|
||||
func testSnapshotStoreRoundTripAndVersionGuard() throws {
|
||||
}
|
||||
|
||||
extension GeolocationCNConverterTests {
|
||||
func testDisablingCNSuffixDoesNotRestoreOmittedOfflineRows() throws {
|
||||
let snapshot = try convert("foo.cn\nexample.com\n")
|
||||
let collection = RulesCollection.load(
|
||||
custom: { [] },
|
||||
builtin: { source in
|
||||
source == .geolocationCN ? snapshot : rulesFixture(source)
|
||||
})
|
||||
let narrow = RuleIdentity(action: .direct, match: .domainSuffix("foo.cn"))
|
||||
XCTAssertFalse(collection.rows.contains { $0.identity == narrow })
|
||||
XCTAssertEqual(
|
||||
try OfflineRuleMatcher.test(collection: collection, address: "foo.cn").outcome, .direct)
|
||||
let disabled = collection.replacingUserDocument(
|
||||
CustomRuleDocument(
|
||||
rules: [], disabledIdentities: [RuleIdentity(action: .direct, match: .domainSuffix("cn"))]))
|
||||
let result = try OfflineRuleMatcher.test(collection: disabled, address: "foo.cn")
|
||||
XCTAssertEqual(result.outcome, .unmatched)
|
||||
XCTAssertTrue(result.deciding.isEmpty)
|
||||
}
|
||||
|
||||
func testSnapshotLoadRejectsOldVersionsAndAbnormalCounts() throws {
|
||||
let snapshot = try convert((0..<100).map { "host\($0).example.com" }.joined(separator: "\n"))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("snapshot.json")
|
||||
let store = RuleSnapshotStore(fileURL: url)
|
||||
let encoded = try RuleSnapshotStore.encode(snapshot)
|
||||
let original = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
var old = original
|
||||
old["schemaVersion"] = 1
|
||||
try JSONSerialization.data(withJSONObject: old).write(to: url)
|
||||
XCTAssertThrowsError(try store.load()) {
|
||||
XCTAssertEqual($0 as? RuleSnapshotError, .schemaVersionMismatch(found: 1, expected: 2))
|
||||
}
|
||||
var obsolete = original
|
||||
var metadata = try XCTUnwrap(obsolete["metadata"] as? [String: Any])
|
||||
metadata["converterVersion"] = "1.0.0"
|
||||
obsolete["metadata"] = metadata
|
||||
try JSONSerialization.data(withJSONObject: obsolete).write(to: url)
|
||||
XCTAssertThrowsError(try store.load()) {
|
||||
XCTAssertEqual(
|
||||
$0 as? RuleSnapshotError,
|
||||
.converterVersionMismatch(found: "1.0.0", expected: "2.0.0"))
|
||||
}
|
||||
let rule = try XCTUnwrap(snapshot.rules.first)
|
||||
for count in [0, 99, 100, 200_000, 200_001] {
|
||||
try store.save(
|
||||
RuleSnapshot(
|
||||
metadata: snapshot.metadata,
|
||||
rules: Array(repeating: rule, count: count)))
|
||||
if (100...200_000).contains(count) {
|
||||
XCTAssertEqual(try store.load().rules.count, count)
|
||||
} else {
|
||||
XCTAssertThrowsError(try store.load()) {
|
||||
XCTAssertEqual(
|
||||
$0 as? RuleSnapshotError,
|
||||
.abnormalRuleCount(found: count, minimum: 100, maximum: 200_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshotEncodingOmitsForensicsAndKeepsNumericReport() throws {
|
||||
let snapshot = try convert("example.com\nfoo.cn\n")
|
||||
let data = try RuleSnapshotStore.encode(snapshot)
|
||||
XCTAssertFalse(data.contains(10), "Snapshot JSON is compact")
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
XCTAssertEqual(Set(json.keys), ["schemaVersion", "metadata", "rules", "lossReport"])
|
||||
let report = try XCTUnwrap(json["lossReport"] as? [String: Any])
|
||||
XCTAssertEqual(Set(report.keys), ["convertedCount", "absorbedCount", "skipped", "rejected"])
|
||||
XCTAssertEqual(report["absorbedCount"] as? Int, 1)
|
||||
let rules = try XCTUnwrap(json["rules"] as? [[String: Any]])
|
||||
XCTAssertTrue(rules.allSatisfy { Set($0.keys) == ["action", "match"] })
|
||||
}
|
||||
|
||||
func testSnapshotStoreRoundTripAndVersionGuard() throws {
|
||||
let snapshot = try convert((0..<100).map { "host\($0).example.com" }.joined(separator: "\n"))
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ssxng-rule-snapshot-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
@@ -90,8 +90,7 @@ final class OfflineRuleMatcherTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testFixedPolicyWinsAndSourceMembershipsRemainComplete() throws {
|
||||
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "fixture", label: "gfw")
|
||||
let proxy = ProxyRule(action: .proxy, match: .domainSuffix("local"), source: source)
|
||||
let proxy = ProxyRule(action: .proxy, match: .domainSuffix("local"))
|
||||
let collection = RulesCollection.load(
|
||||
custom: { [] },
|
||||
builtin: {
|
||||
@@ -110,11 +109,11 @@ final class OfflineRuleMatcherTests: XCTestCase {
|
||||
XCTAssertEqual(local.otherMatches.map(\.identity), [proxy.identity])
|
||||
let match = try RuleMatch(domainExact: "example.com")
|
||||
let custom = CustomRule(action: .proxy, match: match)
|
||||
let duplicate = ProxyRule(action: .proxy, match: match, source: source)
|
||||
let duplicate = ProxyRule(action: .proxy, match: match)
|
||||
let merged = RulesCollection.load(
|
||||
custom: { [custom] },
|
||||
builtin: {
|
||||
rulesFixture($0, absorbed: $0 == .gfwlist ? [duplicate] : [])
|
||||
rulesFixture($0, rules: $0 == .gfwlist ? [duplicate] : [])
|
||||
})
|
||||
let result = try OfflineRuleMatcher.test(collection: merged, address: "example.com")
|
||||
XCTAssertEqual(result.deciding.count, 1)
|
||||
|
||||
@@ -119,13 +119,11 @@ final class ProxyACLTests: XCTestCase {
|
||||
|
||||
/// 规则+未匹配时代理:proxy_all + 固定本地绕过 + 中国域名直连候选。
|
||||
func testRuleProxyDefaultACLUsesProxyAllAndChinaDirectCandidates() throws {
|
||||
let source = RuleSourceIdentity(
|
||||
kind: .geolocationCN, upstreamVersion: "v1", label: "geolocation-cn")
|
||||
let rules = [
|
||||
ProxyRule(action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"), source: source),
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainSuffix: "qq.com"), source: source),
|
||||
ProxyRule(action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn")),
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainSuffix: "qq.com")),
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainExact: "api.example.cn"), source: source),
|
||||
action: .direct, match: try RuleMatch(domainExact: "api.example.cn")),
|
||||
]
|
||||
let acl = ProxyACLDocument.rule(
|
||||
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"),
|
||||
@@ -150,17 +148,13 @@ final class ProxyACLTests: XCTestCase {
|
||||
/// 规则+未匹配时直连(issue #65):bypass_all + 固定本地 + GFWList 可生效
|
||||
/// 代理规则。两种默认动作不把全部内置来源无条件并集,因此不混入中国直连项。
|
||||
func testRuleDirectDefaultACLUsesBypassAllAndGFWListProxyCandidates() throws {
|
||||
let gfwSource = RuleSourceIdentity(
|
||||
kind: .gfwlist, upstreamVersion: "v1", label: "GFWList")
|
||||
let rules = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource),
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example")),
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "also-blocked.example"),
|
||||
source: gfwSource),
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "also-blocked.example")),
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "unshadowed.example"), source: gfwSource
|
||||
),
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "unshadowed.example")),
|
||||
]
|
||||
let acl = ProxyACLDocument.rule(
|
||||
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"),
|
||||
@@ -187,11 +181,9 @@ final class ProxyACLTests: XCTestCase {
|
||||
/// 规则+未匹配时直连:IP 字面目标无可用规则命中时直连(无 IP 代理规则时
|
||||
/// proxy_list 只有域名项)。
|
||||
func testRuleDirectDefaultACLLeavesIPLiteralsUnmatchedDirect() throws {
|
||||
let gfwSource = RuleSourceIdentity(
|
||||
kind: .gfwlist, upstreamVersion: "v1", label: "GFWList")
|
||||
let rules = [
|
||||
ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"), source: gfwSource)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "blocked.example"))
|
||||
]
|
||||
let acl = ProxyACLDocument.rule(
|
||||
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"),
|
||||
@@ -211,17 +203,13 @@ final class ProxyACLTests: XCTestCase {
|
||||
/// 规则+未匹配时代理(issue #64):proxy_all 同时含中国域名与 IPv4 CIDR
|
||||
/// 直连候选,固定本地绕过仍优先。
|
||||
func testRuleProxyDefaultACLIncludesChinaIPv4CIDRCandidates() throws {
|
||||
let domainSource = RuleSourceIdentity(
|
||||
kind: .geolocationCN, upstreamVersion: "v1", label: "geolocation-cn")
|
||||
let cidrSource = RuleSourceIdentity(
|
||||
kind: .chinaIPv4, upstreamVersion: "c1", label: "china-operator-ip")
|
||||
let rules = [
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"), source: domainSource),
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn")),
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: cidrSource),
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24")),
|
||||
ProxyRule(
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "198.51.100.0/24"), source: cidrSource),
|
||||
action: .direct, match: try RuleMatch(ipv4CIDR: "198.51.100.0/24")),
|
||||
]
|
||||
let acl = ProxyACLDocument.rule(
|
||||
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"),
|
||||
@@ -242,21 +230,20 @@ final class ProxyACLTests: XCTestCase {
|
||||
|
||||
/// 冲突元数据不影响 ACL 行;同匹配只输出一行。
|
||||
func testRuleACLLineMapping() throws {
|
||||
let source = RuleSourceIdentity(kind: .custom, upstreamVersion: "v1", label: "custom")
|
||||
XCTAssertEqual(
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"), source: source)
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
.aclLine,
|
||||
"||example.com")
|
||||
XCTAssertEqual(
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainExact: "a.example.com"), source: source)
|
||||
ProxyRule(action: .direct, match: try RuleMatch(domainExact: "a.example.com"))
|
||||
.aclLine,
|
||||
"|a.example.com")
|
||||
XCTAssertEqual(
|
||||
ProxyRule(action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"), source: source)
|
||||
ProxyRule(action: .direct, match: try RuleMatch(ipv4CIDR: "203.0.113.0/24"))
|
||||
.aclLine,
|
||||
"203.0.113.0/24")
|
||||
XCTAssertEqual(
|
||||
ProxyRule(action: .direct, match: try RuleMatch(ipv6CIDR: "2001:db8::/32"), source: source)
|
||||
ProxyRule(action: .direct, match: try RuleMatch(ipv6CIDR: "2001:db8::/32"))
|
||||
.aclLine,
|
||||
"2001:db8::/32")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import XCTest
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 规则领域模型(issue #63):动作、匹配条件、来源身份与冲突元数据独立于
|
||||
/// 规则领域模型(issue #63):动作与匹配条件独立于
|
||||
/// PAC、ACL 文本和系统代理设置。
|
||||
final class ProxyRuleTests: XCTestCase {
|
||||
// MARK: - 动作与匹配
|
||||
@@ -85,54 +85,32 @@ final class ProxyRuleTests: XCTestCase {
|
||||
[.geolocationCN, .chinaIPv4, .gfwlist, .custom])
|
||||
}
|
||||
|
||||
// MARK: - 规则与冲突元数据
|
||||
// MARK: - 规则序列化
|
||||
|
||||
func testRuleCarriesActionMatchSourceAndConflictMetadata() throws {
|
||||
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "v1", label: "geo-cn")
|
||||
let rule = ProxyRule(
|
||||
action: .direct,
|
||||
match: try RuleMatch(domainSuffix: "example.com"),
|
||||
source: source,
|
||||
conflict: RuleConflictMetadata(
|
||||
originalEntry: "domain:example.com",
|
||||
absorbedBy: nil,
|
||||
notes: []))
|
||||
|
||||
XCTAssertEqual(rule.action, .direct)
|
||||
XCTAssertEqual(rule.match, .domainSuffix("example.com"))
|
||||
XCTAssertEqual(rule.source.kind, .geolocationCN)
|
||||
XCTAssertEqual(rule.conflict.originalEntry, "domain:example.com")
|
||||
XCTAssertNil(rule.conflict.absorbedBy)
|
||||
XCTAssertTrue(rule.conflict.notes.isEmpty)
|
||||
}
|
||||
|
||||
func testConflictMetadataRecordsAbsorption() throws {
|
||||
let metadata = RuleConflictMetadata(
|
||||
originalEntry: "foo.cn",
|
||||
absorbedBy: .domainSuffix("cn"),
|
||||
notes: ["absorbed-by-cn-suffix"])
|
||||
XCTAssertEqual(metadata.absorbedBy, .domainSuffix("cn"))
|
||||
XCTAssertEqual(metadata.notes, ["absorbed-by-cn-suffix"])
|
||||
func testRuleEncodesOnlyActionAndMatch() throws {
|
||||
let rule = ProxyRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
let data = try JSONEncoder().encode(rule)
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
XCTAssertEqual(Set(json.keys), ["action", "match"])
|
||||
XCTAssertEqual(try JSONDecoder().decode(ProxyRule.self, from: data), rule)
|
||||
}
|
||||
|
||||
// MARK: - 规则集合规范化
|
||||
|
||||
func testRuleSetDeduplicatesIdenticalRules() throws {
|
||||
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "v1", label: "geo-cn")
|
||||
let first = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
let second = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
let set = RuleSet(rules: [first, second])
|
||||
XCTAssertEqual(set.rules.count, 1)
|
||||
}
|
||||
|
||||
func testRuleSetRecordsCrossActionConflict() throws {
|
||||
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "v1", label: "geo-cn")
|
||||
let direct = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
let proxy = ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "example.com"), source: source)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
let set = RuleSet(rules: [direct, proxy])
|
||||
XCTAssertEqual(set.rules.count, 2, "不同动作的同匹配条件保留双方,冲突由元数据说明")
|
||||
XCTAssertEqual(set.conflicts.count, 1)
|
||||
@@ -143,17 +121,16 @@ final class ProxyRuleTests: XCTestCase {
|
||||
// MARK: - .cn 后缀吸收
|
||||
|
||||
func testCNSuffixAbsorbsSameActionIndependentCNDomains() throws {
|
||||
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "v1", label: "geo-cn")
|
||||
let cnSuffix = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"), source: source)
|
||||
action: .direct, match: try RuleMatch(nationalDomainSuffix: "cn"))
|
||||
let covered = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "foo.cn"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "foo.cn"))
|
||||
let coveredExact = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainExact: "bar.cn"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainExact: "bar.cn"))
|
||||
let otherAction = ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "proxy.cn"), source: source)
|
||||
action: .proxy, match: try RuleMatch(domainSuffix: "proxy.cn"))
|
||||
let unrelated = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
||||
|
||||
let absorbed = RuleCNabsorption.absorb(
|
||||
rules: [cnSuffix, covered, coveredExact, otherAction, unrelated])
|
||||
@@ -165,17 +142,14 @@ final class ProxyRuleTests: XCTestCase {
|
||||
XCTAssertFalse(matches.contains(.domainSuffix("foo.cn")), "同动作 .cn 域名被吸收")
|
||||
XCTAssertFalse(matches.contains(.domainExact("bar.cn")), "同动作 .cn 完整域名被吸收")
|
||||
|
||||
let absorbedEntries = absorbed.absorbed
|
||||
XCTAssertEqual(absorbedEntries.count, 2)
|
||||
XCTAssertTrue(absorbedEntries.allSatisfy { $0.conflict.absorbedBy == .domainSuffix("cn") })
|
||||
XCTAssertEqual(absorbed.absorbedCount, 2)
|
||||
}
|
||||
|
||||
func testCNExactDoesNotAbsorbAnything() throws {
|
||||
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "v1", label: "geo-cn")
|
||||
let cnExact = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainExact: "cn"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainExact: "cn"))
|
||||
let covered = ProxyRule(
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "foo.cn"), source: source)
|
||||
action: .direct, match: try RuleMatch(domainSuffix: "foo.cn"))
|
||||
let result = RuleCNabsorption.absorb(rules: [cnExact, covered])
|
||||
XCTAssertEqual(result.rules.count, 2, "只有 .cn 后缀才能吸收,完整域名 cn 不吸收")
|
||||
}
|
||||
|
||||
@@ -37,22 +37,16 @@ final class RuleAnalysisTests: XCTestCase {
|
||||
.domainSuffix("example.com"))
|
||||
}
|
||||
|
||||
func testAnalysisAndVersionsDoNotDependOnSourceOrCustomOrder() {
|
||||
func testAnalysisAndVersionsDoNotDependOnDuplicateRulesOrCustomOrder() {
|
||||
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 builtIn = [ProxyRule(action: .proxy, match: .domainSuffix("a.example.com"))]
|
||||
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())] {
|
||||
for sourceOrder in [builtIn, builtIn + builtIn] {
|
||||
let actual = RuleAnalysis(rules: order.map(\.proxyRule) + sourceOrder)
|
||||
XCTAssertEqual(actual, expected)
|
||||
XCTAssertEqual(actual.contentVersion, expected.contentVersion)
|
||||
|
||||
@@ -117,7 +117,7 @@ extension ProxyRuntimeControllerTests {
|
||||
RuntimeFileStore(fileURL: runtime.contract).loadDocument()?.aclRuntime?.summary, "direct")
|
||||
}
|
||||
|
||||
func testBatchDisableRestartsOnceAndRestoresAbsorbedChinaCandidate() async throws {
|
||||
func testBatchDisableRestartsOnceWithoutRestoringOmittedChinaCandidate() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let (store, _) = try makeCustomRuleStore()
|
||||
let settings = ProxySettings(
|
||||
@@ -128,52 +128,24 @@ extension ProxyRuntimeControllerTests {
|
||||
try await controller.activate(seeded.server)
|
||||
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
|
||||
let before = agent.unregisterCount
|
||||
let cn = RuleIdentity(action: .direct, match: .domainSuffix("cn"))
|
||||
let cnSuffix = RuleIdentity(action: .direct, match: .domainSuffix("cn"))
|
||||
let absent = RuleIdentity(action: .direct, match: .domainExact("absent.example"))
|
||||
let outcome = await controller.updateRuleDocument(
|
||||
CustomRuleDocument(rules: [], disabledIdentities: [cn, absent]))
|
||||
CustomRuleDocument(rules: [], disabledIdentities: [cnSuffix, absent]))
|
||||
XCTAssertEqual(outcome, .applied)
|
||||
XCTAssertEqual(agent.unregisterCount, before + 1)
|
||||
let content = try activeACLContent(runtimeStore)
|
||||
XCTAssertFalse(content.split(separator: "\n").contains("||cn"))
|
||||
let snapshot = try BuiltinRuleCatalog.loadGeolocationCN(from: AppArtifact.bundle)
|
||||
let narrow = try XCTUnwrap(snapshot.absorbed.first)
|
||||
XCTAssertTrue(content.contains(narrow.aclLine))
|
||||
XCTAssertGreaterThan(snapshot.lossReport.absorbedCount, 0)
|
||||
XCTAssertFalse(content.split(separator: "\n").contains("||baidu.cn"))
|
||||
let noOpBefore = agent.unregisterCount
|
||||
let unchanged = await controller.updateRuleDocument(
|
||||
CustomRuleDocument(rules: [], disabledIdentities: [cn]))
|
||||
CustomRuleDocument(rules: [], disabledIdentities: [cnSuffix]))
|
||||
XCTAssertEqual(unchanged, .runtimeUnchanged)
|
||||
XCTAssertEqual(agent.unregisterCount, noOpBefore, "Absent identities change only persistence")
|
||||
}
|
||||
|
||||
func testDisablingGFWBlockersRestoresPreservedExpressibleException() 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 snapshot = try BuiltinRuleCatalog.loadGFWList(from: AppArtifact.bundle)
|
||||
let exception = try XCTUnwrap(snapshot.absorbed.first)
|
||||
let blockers = Set(
|
||||
(snapshot.rules + snapshot.absorbed).filter {
|
||||
$0.action == .proxy && RuleCoverage.domainCovers($0.match, exception.match)
|
||||
}.map(\.identity))
|
||||
XCTAssertFalse(blockers.isEmpty)
|
||||
let before = agent.unregisterCount
|
||||
let outcome = await controller.updateRuleDocument(
|
||||
CustomRuleDocument(rules: [], disabledIdentities: blockers))
|
||||
XCTAssertEqual(outcome, .applied)
|
||||
XCTAssertEqual(agent.unregisterCount, before + 1)
|
||||
let candidates = try await controller.ruleModeCandidateRules()
|
||||
XCTAssertTrue(
|
||||
candidates.contains {
|
||||
$0.action == .direct && RuleCoverage.domainCovers($0.match, exception.match)
|
||||
})
|
||||
}
|
||||
|
||||
func testOffSaveUsesOwnedDocumentAndNewSessionRejectsCorruption() async throws {
|
||||
let (store, _) = try makeCustomRuleStore()
|
||||
let controller = makeControllerWithCustomRules(
|
||||
|
||||
@@ -76,6 +76,6 @@ private final class ReportFixtureLoader: @unchecked Sendable {
|
||||
kind: metadata.source.kind, upstreamVersion: "v\(version)", label: metadata.source.label),
|
||||
upstreamReference: metadata.upstreamReference, inputDigest: "digest-\(version)",
|
||||
fetchedAt: metadata.fetchedAt, license: metadata.license, attribution: metadata.attribution),
|
||||
rules: fixture.rules, lossReport: RuleConversionLossReport(notes: ["v\(version)"]))
|
||||
rules: fixture.rules, lossReport: RuleConversionLossReport(absorbedCount: version))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,11 @@ final class RulesWorkflowCoverageTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testFixedProtectionExplainsSimpleHostsAndPreventsFalseLocalShadowing() async throws {
|
||||
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "gfwlist")
|
||||
let simple = ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainExact: "printer"), source: source)
|
||||
let simpleSuffix = ProxyRule(action: .proxy, match: .domainSuffix("cn"), source: source)
|
||||
action: .proxy, match: try RuleMatch(domainExact: "printer"))
|
||||
let simpleSuffix = ProxyRule(action: .proxy, match: .domainSuffix("cn"))
|
||||
let localProxy = ProxyRule(
|
||||
action: .proxy, match: try RuleMatch(domainExact: "device.local"), source: source)
|
||||
action: .proxy, match: try RuleMatch(domainExact: "device.local"))
|
||||
let localDirect = CustomRule(action: .direct, match: try RuleMatch(domainExact: "device.local"))
|
||||
let nationalDirect = CustomRule(action: .direct, match: .domainSuffix("cn"))
|
||||
let workflow = RulesWorkflow(
|
||||
@@ -77,8 +76,7 @@ final class RulesWorkflowCoverageTests: XCTestCase {
|
||||
|
||||
func testFullyFixedProtectedProxyDoesNotShadowAnUnprotectedDirectSuffix() async throws {
|
||||
let direct = CustomRule(action: .direct, match: .domainSuffix("cn"))
|
||||
let source = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "gfwlist")
|
||||
let proxy = ProxyRule(action: .proxy, match: try RuleMatch(domainExact: "cn"), source: source)
|
||||
let proxy = ProxyRule(action: .proxy, match: try RuleMatch(domainExact: "cn"))
|
||||
let workflow = RulesWorkflow(
|
||||
loadCustom: { [direct] },
|
||||
loadBuiltin: { requested in
|
||||
|
||||
@@ -29,11 +29,10 @@ final class RulesWorkflowTests: XCTestCase {
|
||||
},
|
||||
loadBuiltin: { source in
|
||||
guard source == .gfwlist else { return rulesFixture(source) }
|
||||
let identity = RuleSourceIdentity(kind: .gfwlist, upstreamVersion: "v1", label: "fixture")
|
||||
return rulesFixture(
|
||||
source,
|
||||
rules: [
|
||||
ProxyRule(action: .proxy, match: broad.match, source: identity)
|
||||
ProxyRule(action: .proxy, match: broad.match)
|
||||
])
|
||||
})
|
||||
await workflow.refresh()
|
||||
@@ -61,7 +60,7 @@ final class RulesWorkflowTests: XCTestCase {
|
||||
XCTAssertFalse(try store.loadDocument().disabledIdentities.contains(orphan))
|
||||
}
|
||||
|
||||
func testEquivalentSourcesMergeAndAbsorbedCandidatesRemainBrowsable() async throws {
|
||||
func testEquivalentSourcesMergeForShippedRules() async throws {
|
||||
let match = try RuleMatch(domainExact: "Example.COM")
|
||||
let custom = CustomRule(action: .direct, match: match)
|
||||
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "fixture", label: "geo")
|
||||
@@ -69,7 +68,7 @@ final class RulesWorkflowTests: XCTestCase {
|
||||
metadata: RuleSnapshotMetadata(
|
||||
source: source, upstreamReference: "upstream", inputDigest: "digest",
|
||||
fetchedAt: Date(timeIntervalSince1970: 0), license: "MIT", attribution: "author"),
|
||||
rules: [], absorbed: [ProxyRule(action: .direct, match: match, source: source)])
|
||||
rules: [ProxyRule(action: .direct, match: match)])
|
||||
let workflow = RulesWorkflow(
|
||||
loadCustom: { [custom] },
|
||||
loadBuiltin: { source in source == .geolocationCN ? snapshot : rulesFixture(source) })
|
||||
@@ -150,7 +149,7 @@ final class RulesWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(workflow.snapshot.issues.count, 2)
|
||||
}
|
||||
|
||||
func testPackagedCollectionLoadsMetadataAbsorbedRulesAndFixedSemanticPolicy() async throws {
|
||||
func testPackagedCollectionLoadsMetadataShippedRulesAndFixedSemanticPolicy() async throws {
|
||||
let bundle = AppArtifact.bundle
|
||||
let workflow = RulesWorkflow(
|
||||
loadCustom: { [] },
|
||||
@@ -167,7 +166,7 @@ final class RulesWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(workflow.snapshot.sources.count, 5)
|
||||
XCTAssertTrue(workflow.snapshot.rows.contains { $0.id == .noDotHostname && $0.isFixed })
|
||||
let geo = try XCTUnwrap(workflow.snapshot.sources.first { $0.id == .geolocationCN })
|
||||
XCTAssertEqual(geo.count, 5379)
|
||||
XCTAssertEqual(geo.count, 4241)
|
||||
XCTAssertEqual(geo.metadata?.source.upstreamVersion, "20260925234224")
|
||||
XCTAssertNotNil(geo.conversionReport)
|
||||
let rows = workflow.snapshot.rows
|
||||
@@ -179,7 +178,7 @@ final class RulesWorkflowTests: XCTestCase {
|
||||
})
|
||||
workflow.query(RulesQuery(source: .geolocationCN))
|
||||
XCTAssertTrue(workflow.snapshot.rows.contains { $0.content == "cn" })
|
||||
XCTAssertGreaterThan(workflow.snapshot.rows.count, 4241)
|
||||
XCTAssertEqual(workflow.snapshot.rows.count, 4241)
|
||||
}
|
||||
|
||||
func testRefreshPublishesVersionAndRowsTogetherAndInvalidatesRemovedSelection() async throws {
|
||||
@@ -286,7 +285,7 @@ extension RulesWorkflowTests {
|
||||
}
|
||||
|
||||
func rulesFixture(
|
||||
_ source: RulesSource, rules: [ProxyRule] = [], absorbed: [ProxyRule] = []
|
||||
_ source: RulesSource, rules: [ProxyRule] = []
|
||||
) -> RuleSnapshot {
|
||||
let kind: RuleSourceKind
|
||||
switch source {
|
||||
@@ -300,7 +299,7 @@ func rulesFixture(
|
||||
source: RuleSourceIdentity(kind: kind, upstreamVersion: "v1", label: source.rawValue),
|
||||
upstreamReference: "https://example.com/source", inputDigest: "fixture",
|
||||
fetchedAt: Date(timeIntervalSince1970: 0),
|
||||
license: "MIT", attribution: "fixture author"), rules: rules, absorbed: absorbed)
|
||||
license: "MIT", attribution: "fixture author"), rules: rules)
|
||||
}
|
||||
|
||||
private final class RulesReadBarrier: @unchecked Sendable {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"converterVersion": "1.0.0",
|
||||
"converterVersion": "2.0.0",
|
||||
"name": "china-ipv4",
|
||||
"snapshotSHA256": "f97086c877982e860d0fb3ee5564e23e6c9f934b8e053a128150551e309296c6",
|
||||
"snapshotSHA256": "cb5f9974973bf4b2e8c609105ae15c96924ee5bfd8c85753fc091f8cc13380dd",
|
||||
"upstreamArchiveSHA256": "98493d3ec7f2c77b9f191600dc741df731cfbaef775201594d0bda66b0181479",
|
||||
"upstreamURL": "https://raw.githubusercontent.com/gaoyifan/china-operator-ip/75f2eb035cb53a7a4af3437e2c2110d0f73cf14a/china.txt",
|
||||
"upstreamVersion": "75f2eb035cb53a7a4af3437e2c2110d0f73cf14a"
|
||||
|
||||
+1
-105530
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"converterVersion": "1.0.0",
|
||||
"converterVersion": "2.0.0",
|
||||
"name": "geolocation-cn",
|
||||
"snapshotSHA256": "68b70dbf03731d78a9ec8078b9e4736c8c5e1c85d3f37c858c0862158e841d85",
|
||||
"snapshotSHA256": "c3157d61e7bb25f39486974a937c97967872ffe82d79e91364ee5db5022e7f39",
|
||||
"upstreamArchiveSHA256": "db12e746ef387c34c9e1be8194d772a0606ef09c236815555af5ba2595f3741f",
|
||||
"upstreamURL": "https://github.com/Loyalsoldier/domain-list-custom/releases/download/20260925234224/geosite.dat",
|
||||
"upstreamVersion": "20260925234224"
|
||||
|
||||
+1
-97168
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"converterVersion": "1.0.0",
|
||||
"converterVersion": "2.0.0",
|
||||
"name": "gfwlist",
|
||||
"snapshotSHA256": "cad162e57a5f75e5173d971cdc7db167b1689230458783f03052b274dc0b698c",
|
||||
"snapshotSHA256": "dc79a7205cbfd04e598aba9db6dd97ce75a618709111175b20a0aeec5bde58f8",
|
||||
"upstreamArchiveSHA256": "750d34564363075fafa12d8ab9739f269e2fbd2bf3c5f4ab8cfd859bfd3441df",
|
||||
"upstreamURL": "https://raw.githubusercontent.com/gfwlist/gfwlist/c19292511da4c5af8f6193fd32d666eaaa0312f9/gfwlist.txt",
|
||||
"upstreamVersion": "c19292511da4c5af8f6193fd32d666eaaa0312f9"
|
||||
|
||||
+1
-69826
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
# Built-in rule snapshots drop absorbed candidates and conversion forensics
|
||||
|
||||
**Status**: accepted.
|
||||
|
||||
Snapshots previously shipped every converted entry: effective rules plus same-action absorbed candidates (for example geolocation-cn domains covered by the synthesized `.cn` suffix) and GFWList `@@` exceptions shadowed by a broader proxy rule, each carrying conversion forensics (`originalEntry`, `absorbedBy`, per-rule notes). Runtime analysis recomputes absorption and shadowing from matches alone and never read those forensics, so the extra payload only widened the snapshot interface and slowed browsing and offline matching.
|
||||
|
||||
Maintainer updates now omit absorbed and shadowed entries from the snapshot entirely. The conversion report keeps numeric totals only (`convertedCount`, `absorbedCount`, per-reason `skipped`/`rejected`); individual upstream strings go to converter stdout, not into the shipped artifact. Each rule is just an action and a match; the source identity lives once at snapshot level. We accept that disabling a broader built-in rule (such as `.cn`) no longer restores narrower built-in candidates — a rare recovery path not worth carrying thousands of extra rows on every load. Custom-rule disablement, offline testing, and ACL compilation continue to work from the shipped effective set.
|
||||
|
||||
The artifact contract is schema version 2 with converter version 2.0.0 and compact JSON. `rules` contains only `{action, match}`; source identity appears only in `metadata.source`. `lossReport` contains `convertedCount`, `absorbedCount`, `skipped`, and `rejected`, with no notes or individual entries. Shadowed GFWList exceptions contribute only to `absorbedCount`, without a second count under `skipped`. Unsupported syntax retains its category count. Detailed conversion evidence is printed to maintainer stdout and is not saved as a sidecar.
|
||||
|
||||
The loader rejects old schema/converter versions and rule counts outside the generator limits: 100–200000 for geolocation-cn and 100–50000 for china-ipv4 and gfwlist. Source validation uses snapshot metadata; custom-rule documents keep their existing source metadata and schema. Browsing and independent offline matching consume only shipped rules, while relationships among surviving built-in and saved custom rules are still computed dynamically. Disabling `.cn` also removes the omitted domains from offline explanations; disabling a GFWList blocker does not bring back its omitted `@@` exceptions.
|
||||
|
||||
This supersedes the preserved-candidate recovery invariant in `CONTEXT.md`. Python generation and Swift converter implementations remain separate; consolidating them is outside this change.
|
||||
Reference in New Issue
Block a user