mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-15 02:37:32 +00:00
214 lines
8.6 KiB
Swift
214 lines
8.6 KiB
Swift
import Foundation
|
|
|
|
/// Browsing includes all shipped rules and saved custom entries without changing runtime sources.
|
|
struct RulesCollection: Sendable {
|
|
let version: String
|
|
let rows: [RulesRow]
|
|
let sources: [RulesSourceSnapshot]
|
|
let issues: [RulesPageSnapshot.Issue]
|
|
let userDocument: CustomRuleDocument?
|
|
fileprivate let builtinInput: RulesCollectionInput
|
|
|
|
static func load(
|
|
custom loadCustom: () throws -> [CustomRule],
|
|
builtin loadBuiltin: (RulesSource) throws -> RuleSnapshot,
|
|
document loadDocument: (() throws -> CustomRuleDocument)? = nil
|
|
) -> RulesCollection {
|
|
var builtin = RulesCollectionInput()
|
|
for source in [RulesSource.geolocationCN, .chinaIPv4, .gfwlist] {
|
|
builtin.readBuiltin(source, using: loadBuiltin)
|
|
}
|
|
var input = builtin
|
|
input.readCustom { try loadDocument?() ?? CustomRuleDocument(rules: loadCustom()) }
|
|
return input.collection(builtinInput: builtin)
|
|
}
|
|
|
|
/// A single prospective row uses the same analysis as browsing without sorting
|
|
/// or recomputing every saved row on each editor keystroke.
|
|
func previewRow(identity: RuleIdentity, document: CustomRuleDocument) -> RulesRow? {
|
|
var input = builtinInput
|
|
input.readCustom { document }
|
|
return input.previewRow(identity: identity)
|
|
}
|
|
|
|
func replacingUserDocument(_ document: CustomRuleDocument) -> RulesCollection {
|
|
var input = builtinInput
|
|
input.readCustom { document }
|
|
return input.collection(builtinInput: builtinInput)
|
|
}
|
|
}
|
|
|
|
private struct RulesCandidate: Sendable {
|
|
let rule: ProxyRule
|
|
let source: RulesSource
|
|
let customID: UUID?
|
|
}
|
|
|
|
private struct RulesCollectionInput: Sendable {
|
|
var userDocument: CustomRuleDocument?
|
|
var disabled: Set<RuleIdentity> = []
|
|
var entries: [RulesCandidate] = []
|
|
var sources: [RulesSourceSnapshot] = []
|
|
var issues: [RulesPageSnapshot.Issue] = []
|
|
var metadataTokens: [String] = []
|
|
|
|
mutating func readCustom(_ loadCustom: () throws -> CustomRuleDocument) {
|
|
do {
|
|
let document = try loadCustom()
|
|
let custom = document.rules
|
|
disabled = document.disabledIdentities
|
|
userDocument = document
|
|
let validation = CustomRuleValidator.hardValidation(custom: custom)
|
|
guard validation.rejected.isEmpty else {
|
|
throw CustomRuleStoreError.corrupt(detail: "Invalid custom rule collection")
|
|
}
|
|
entries += custom.map { RulesCandidate(rule: $0.proxyRule, source: .custom, customID: $0.id) }
|
|
sources.insert(
|
|
RulesSourceSnapshot(id: .custom, count: custom.count, metadata: nil, conversionReport: nil),
|
|
at: 0)
|
|
} catch {
|
|
issues.insert(.userDocument(String(describing: error)), at: 0)
|
|
}
|
|
}
|
|
|
|
mutating func readBuiltin(
|
|
_ source: RulesSource, using loadBuiltin: (RulesSource) throws -> RuleSnapshot
|
|
) {
|
|
do {
|
|
let snapshot = try loadBuiltin(source)
|
|
let expected: RuleSourceKind
|
|
switch source {
|
|
case .geolocationCN: expected = .geolocationCN
|
|
case .chinaIPv4: expected = .chinaIPv4
|
|
case .gfwlist: expected = .gfwlist
|
|
case .custom, .fixed: expected = .custom
|
|
}
|
|
guard snapshot.metadata.source.kind == expected
|
|
else { throw RuleSnapshotError.corrupt(detail: "Unexpected rule source") }
|
|
let candidates = snapshot.rules
|
|
entries += candidates.map { RulesCandidate(rule: $0, source: source, customID: nil) }
|
|
sources.append(
|
|
RulesSourceSnapshot(
|
|
id: source, count: candidates.count,
|
|
metadata: snapshot.metadata, conversionReport: snapshot.lossReport))
|
|
// Metadata changes invalidate details even when rule content is unchanged.
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
metadataTokens.append(
|
|
source.rawValue + ProxyACLDocument.digest(try encoder.encode(snapshot.metadata)))
|
|
metadataTokens.append(
|
|
ProxyACLDocument.digest(try encoder.encode(snapshot.lossReport)))
|
|
} catch {
|
|
issues.append(.builtin(source, String(describing: error)))
|
|
}
|
|
}
|
|
|
|
private mutating func appendFixedPolicy() {
|
|
let fixedMatches = RuleCoverage.fixedLocalMatches
|
|
entries += fixedMatches.map {
|
|
RulesCandidate(
|
|
rule: ProxyRule(action: .direct, match: $0), source: .fixed,
|
|
customID: nil)
|
|
}
|
|
sources.append(
|
|
RulesSourceSnapshot(
|
|
id: .fixed, count: fixedMatches.count + 1, metadata: nil, conversionReport: nil))
|
|
}
|
|
|
|
mutating func previewRow(identity: RuleIdentity) -> RulesRow? {
|
|
appendFixedPolicy()
|
|
let memberships = entries.filter { $0.rule.identity == identity }
|
|
guard !memberships.isEmpty else { return nil }
|
|
let overlapping = entries.compactMap { candidate -> ProxyRule? in
|
|
let rule = candidate.rule
|
|
guard !disabled.contains(rule.identity),
|
|
RuleCoverage.intersects(rule.identity.match, identity.match),
|
|
rule.action != .proxy
|
|
|| RuleCoverage.fixedLocalCoverage(of: rule.identity.match)?.extent != .full
|
|
else { return nil }
|
|
return rule
|
|
}
|
|
return row(identity: identity, entries: memberships, overlapping: overlapping)
|
|
}
|
|
|
|
mutating func collection(builtinInput: RulesCollectionInput) -> RulesCollection {
|
|
appendFixedPolicy()
|
|
let grouped = Dictionary(grouping: entries, by: { $0.rule.identity })
|
|
let rules = grouped.values.compactMap { $0.first?.rule }
|
|
// Completely fixed-protected proxy candidates remain browsable but cannot
|
|
// cover or shadow another candidate in the effective collection.
|
|
let effective = rules.filter {
|
|
!disabled.contains($0.identity)
|
|
&& ($0.action != .proxy
|
|
|| RuleCoverage.fixedLocalCoverage(of: $0.identity.match)?.extent != .full)
|
|
}
|
|
let index = RulesOverlapIndex(rules: effective)
|
|
var rows = grouped.map { identity, entries in
|
|
row(identity: identity, entries: entries, overlapping: index.overlapping(identity.match))
|
|
}
|
|
for identity in disabled where grouped[identity] == nil {
|
|
rows.append(
|
|
RulesRow(
|
|
id: .rule(identity), identity: identity,
|
|
content: identity.match.browsingContent, sources: [], customIDs: [],
|
|
relationships: [], fixedCoverage: nil, isEnabled: false))
|
|
}
|
|
rows.append(
|
|
RulesRow(
|
|
id: .noDotHostname, identity: nil, content: "^[^.]+$", sources: [.fixed], customIDs: [],
|
|
relationships: [], fixedCoverage: nil))
|
|
// Tuple fields are evaluated eagerly. Prepare reflection-based identity
|
|
// tokens once per row instead of rebuilding them on every sort comparison.
|
|
rows = rows.map { row in
|
|
(row: row, key: (row.content, row.action.rawValue, row.identity?.contentToken ?? ""))
|
|
}.sorted { $0.key < $1.key }.map(\.row)
|
|
let tokens =
|
|
rows.map { row in
|
|
(row.identity?.contentToken ?? "fixed:no-dot") + "|"
|
|
+ row.sources.map(\.rawValue).sorted().joined(separator: ",")
|
|
+ "|" + String(row.isEnabled) + "|"
|
|
+ row.customIDs.map(\.uuidString).sorted().joined(separator: ",")
|
|
} + metadataTokens.sorted() + issues.map { String(describing: $0) }
|
|
return RulesCollection(
|
|
version: String(ProxyACLDocument.digest(tokens.sorted().joined(separator: "\n")).prefix(16)),
|
|
rows: rows, sources: sources, issues: issues, userDocument: userDocument,
|
|
builtinInput: builtinInput)
|
|
}
|
|
private func row(
|
|
identity: RuleIdentity, entries: [RulesCandidate], overlapping: [ProxyRule]
|
|
) -> RulesRow {
|
|
let memberships = Set(entries.map { $0.source })
|
|
let representative = entries[0].rule
|
|
var relationships =
|
|
memberships.contains(.fixed)
|
|
? [] : RuleAnalysis(rules: overlapping, subjects: [representative]).relationships
|
|
let fixedCoverage =
|
|
memberships.contains(.fixed) ? nil : RuleCoverage.fixedLocalCoverage(of: identity.match)
|
|
if identity.action == .direct, let fixedCoverage {
|
|
relationships = relationships.compactMap { relationship in
|
|
guard relationship.kind == .shadowing else { return relationship }
|
|
guard fixedCoverage.extent == .partial else { return nil }
|
|
return RuleRelationship(
|
|
rule: identity, kind: .shadowing, extent: .partial,
|
|
covering: relationship.covering)
|
|
}
|
|
}
|
|
return RulesRow(
|
|
id: .rule(identity), identity: identity, content: identity.match.browsingContent,
|
|
sources: memberships, customIDs: Set(entries.compactMap { $0.customID }),
|
|
relationships: disabled.contains(identity) ? [] : relationships, fixedCoverage: fixedCoverage,
|
|
isEnabled: memberships.contains(.fixed) || !disabled.contains(identity))
|
|
}
|
|
|
|
}
|
|
|
|
extension RuleMatch {
|
|
var browsingContent: String {
|
|
switch self {
|
|
case .domainExact(let value), .domainSuffix(let value), .ipv4CIDR(let value),
|
|
.ipv6CIDR(let value):
|
|
value
|
|
}
|
|
}
|
|
}
|