mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-09 04:34:51 +00:00
264 lines
10 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|