Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/Rules/RulesCollection.swift
T

264 lines
10 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
fileprivate let builtinVersion: String
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, builtinVersion: builtin.contentVersion)
}
/// A single prospective row uses the same analysis as browsing without sorting
/// or recomputing every saved row on each editor keystroke.
func previewRow(identity: ProxyRule, document: CustomRuleDocument) -> RulesRow? {
var input = builtinInput
input.readCustom { document }
return input.previewRow(identity: identity)
}
func replacingUserDocument(
_ document: CustomRuleDocument, analyzing: Bool = true
) -> RulesCollection {
var input = builtinInput
input.readCustom { document }
return input.collection(
builtinInput: builtinInput, builtinVersion: builtinVersion,
cachedRows: analyzing ? nil : Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }))
}
}
private struct RulesCandidate: Sendable {
let rule: ProxyRule
var identity: ProxyRule { rule }
let source: RulesSource
let customID: UUID?
init(rule: ProxyRule, source: RulesSource, customID: UUID?) {
self.rule = rule
self.source = source
self.customID = customID
}
}
private struct RulesCollectionInput: Sendable {
var userDocument: CustomRuleDocument?
var disabled: Set<ProxyRule> = []
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)
guard let expected = source.sourceKind else { throw RuleSnapshotError.missing }
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.
metadataTokens += try RuleSnapshotStore.metadataTokens(for: snapshot)
} 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: ProxyRule) -> RulesRow? {
appendFixedPolicy()
let memberships = entries.filter { $0.identity == identity }
guard !memberships.isEmpty else { return nil }
let overlapping = entries.compactMap { candidate -> ProxyRule? in
let rule = candidate.rule
guard !disabled.contains(rule),
RuleCoverage.intersects(rule.match, identity.match),
rule.action != .proxy
|| RuleCoverage.fixedLocalCoverage(of: rule.match)?.extent != .full
else { return nil }
return rule
}
return row(identity: identity, entries: memberships, overlapping: overlapping)
}
var contentVersion: String {
let tokens =
entries.map { $0.identity.contentToken + "|" + $0.source.rawValue }
+ metadataTokens + issues.map { String(describing: $0) }
return ProxyACLDocument.digest(tokens.sorted().joined(separator: "\n"))
}
private func documentVersion(builtinVersion: String) -> String {
let tokens =
(userDocument?.rules ?? []).map { $0.identity.contentToken + "|" + $0.id.uuidString }
+ disabled.map { "disabled:" + $0.contentToken }
return String(
ProxyACLDocument.digest(
builtinVersion + "\n" + tokens.sorted().joined(separator: "\n")
).prefix(16))
}
mutating func collection(
builtinInput: RulesCollectionInput, builtinVersion: String,
cachedRows: [RulesRow.SelectionID: RulesRow]? = nil
) -> RulesCollection {
appendFixedPolicy()
let grouped = Dictionary(grouping: entries, by: { $0.identity })
// Completely fixed-protected proxy candidates remain browsable but cannot
// cover or shadow another candidate in the effective collection.
let index =
cachedRows == nil
? RulesOverlapIndex(
rules: grouped.values.compactMap { $0.first?.rule }.filter {
!disabled.contains($0)
&& ($0.action != .proxy
|| RuleCoverage.fixedLocalCoverage(of: $0.match)?.extent != .full)
}) : nil
var rows = orderedRows(grouped: grouped, index: index, cachedRows: cachedRows)
let orphans = disabled.filter { grouped[$0] == nil }.map { ($0, $0.contentToken) }
.sorted { $0.1 < $1.1 }.map(\.0)
for identity in orphans {
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))
return RulesCollection(
version: documentVersion(builtinVersion: builtinVersion),
rows: rows, sources: sources, issues: issues, userDocument: userDocument,
builtinInput: builtinInput, builtinVersion: builtinVersion)
}
private func cachedCoverage(
identity: ProxyRule, old: RulesRow?, entries: [RulesCandidate]
) -> FixedRuleCoverage? {
if let old { return old.fixedCoverage }
return entries.contains { $0.source == .fixed }
? nil : RuleCoverage.fixedLocalCoverage(of: identity.match)
}
private func orderedRows(
grouped: [ProxyRule: [RulesCandidate]], index: RulesOverlapIndex?,
cachedRows: [RulesRow.SelectionID: RulesRow]?
) -> [RulesRow] {
// Preserve the first source occurrence, rather than Dictionary iteration order.
var seen: Set<ProxyRule> = []
var rows: [RulesRow] = []
for entry in entries {
let identity = entry.identity
guard seen.insert(identity).inserted else { continue }
let memberships = grouped[identity, default: []]
if let cachedRows {
let old = cachedRows[.rule(identity)]
rows.append(
RulesRow(
id: .rule(identity), identity: identity, content: identity.match.browsingContent,
sources: Set(memberships.map(\.source)),
customIDs: Set(memberships.compactMap(\.customID)),
relationships: old?.relationships ?? [],
fixedCoverage: cachedCoverage(identity: identity, old: old, entries: memberships),
isEnabled: memberships.contains { $0.source == .fixed } || !disabled.contains(identity))
)
} else {
rows.append(
row(
identity: identity, entries: memberships,
overlapping: disabled.contains(identity) ? [] : index!.overlapping(identity.match)))
}
}
return rows
}
private func row(
identity: ProxyRule, 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 pattern {
case .domainExact(let value), .domainSuffix(let value), .ipv4CIDR(let value),
.ipv6CIDR(let value):
value
}
}
}