Files

178 lines
6.1 KiB
Swift

import Foundation
enum CustomRuleStoreError: Error, Equatable, Sendable {
case missing
case corrupt(detail: String)
case schemaVersionMismatch(found: Int, expected: Int)
case ioFailure(detail: String)
}
/// 自定义规则持久化:独立 JSON 文档,原子写盘。
/// Saving intent is independent of subsequent ACL application and runtime recovery.
struct CustomRuleStore {
static let currentSchemaVersion = 4
let fileURL: URL
let baseURL: URL
let preset: RulePreset?
init(fileURL: URL = CustomRuleStore.defaultFileURL()) {
self.fileURL = fileURL
self.baseURL = fileURL
self.preset = nil
}
init(preset: RulePreset, baseURL: URL = CustomRuleStore.defaultFileURL()) {
self.baseURL = baseURL
self.preset = preset
self.fileURL = baseURL.deletingLastPathComponent()
.appendingPathComponent("rule-presets", isDirectory: true)
.appendingPathComponent(preset.rawValue, isDirectory: true)
.appendingPathComponent(baseURL.lastPathComponent)
}
static func defaultFileURL() -> URL {
ApplicationSupportPaths.current.customRulesFileURL
}
/// 读取规则集合;文件不存在视为无自定义规则。
func load() throws -> [CustomRule] { try loadDocument().rules }
func loadDocument() throws -> CustomRuleDocument {
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch let error as CocoaError where error.code == .fileReadNoSuchFile {
return CustomRuleDocument(rules: [])
} catch {
throw CustomRuleStoreError.ioFailure(detail: String(describing: error))
}
let document: CustomRuleDocument
do {
let stored = try JSONDecoder().decode(StoredCustomRuleDocument.self, from: data)
let decoded = try stored.domain()
if let preset, stored.schemaVersion < Self.currentSchemaVersion {
document = CustomRuleDocument(
rules: decoded.rules.filter { $0.action == preset.matchedAction },
disabledIdentities: decoded.disabledIdentities)
} else {
try validateActions(decoded)
document = decoded
}
} catch let error as CustomRuleStoreError {
throw error
} catch {
throw CustomRuleStoreError.corrupt(detail: String(describing: error))
}
return document
}
/// 原子写入完整规则集合。
func save(_ rules: [CustomRule]) throws {
let old = try loadDocument()
try saveDocument(CustomRuleDocument(rules: rules, disabledIdentities: old.disabledIdentities))
}
func saveDocument(_ document: CustomRuleDocument) throws {
try validateActions(document)
let data = try Self.encode(document)
_ = try Self.decode(data)
do {
try AtomicFileWriter.write(data, to: fileURL)
} catch {
throw CustomRuleStoreError.ioFailure(detail: String(describing: error))
}
}
private func validateActions(_ document: CustomRuleDocument) throws {
guard let preset else { return }
guard document.rules.allSatisfy({ $0.action == preset.matchedAction }) else {
throw CustomRuleStoreError.corrupt(detail: "Custom rule action must match its rule scheme")
}
}
/// 安全摘要:数量 + 内容版本。
func summary() throws -> CustomRuleSummary {
CustomRuleSummary.summarizing(try loadDocument())
}
static func decode(_ data: Data) throws -> CustomRuleDocument {
try JSONDecoder().decode(StoredCustomRuleDocument.self, from: data).domain()
}
static func encode(_ document: CustomRuleDocument) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(StoredCustomRuleDocument(document))
}
}
private struct StoredCustomRule: Codable {
let id: UUID
let rule: StoredProxyRule
init(_ custom: CustomRule) {
id = custom.id
rule = StoredProxyRule(custom.rule)
}
private enum CodingKeys: CodingKey { case id }
init(from decoder: Decoder) throws {
id = try decoder.container(keyedBy: CodingKeys.self).decode(UUID.self, forKey: .id)
rule = try StoredProxyRule(from: decoder)
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try rule.encode(to: encoder)
}
func domain() throws -> CustomRule {
let value = try rule.domain()
guard !value.match.isFixedLocalSuffix else {
throw CustomRuleStoreError.corrupt(detail: "Fixed local suffix is not a custom rule")
}
return CustomRule(id: id, action: value.action, match: value.match)
}
}
private struct StoredCustomRuleDocument: Codable {
let schemaVersion: Int
let rules: [StoredCustomRule]
let disabledIdentities: [StoredProxyRule]
init(_ document: CustomRuleDocument) {
schemaVersion = CustomRuleStore.currentSchemaVersion
rules = document.rules.map(StoredCustomRule.init)
disabledIdentities = document.disabledIdentities.sorted { $0.contentToken < $1.contentToken }
.map(StoredProxyRule.init)
}
private enum CodingKeys: CodingKey { case schemaVersion, rules, disabledIdentities }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decode(Int.self, forKey: .schemaVersion)
guard (1...CustomRuleStore.currentSchemaVersion).contains(schemaVersion) else {
throw CustomRuleStoreError.schemaVersionMismatch(
found: schemaVersion, expected: CustomRuleStore.currentSchemaVersion)
}
rules = try values.decode([StoredCustomRule].self, forKey: .rules)
disabledIdentities =
schemaVersion == 1
? [] : try values.decode([StoredProxyRule].self, forKey: .disabledIdentities)
}
func domain() throws -> CustomRuleDocument {
let custom = try rules.map { try $0.domain() }
let disabled = try disabledIdentities.map { try $0.domain() }
guard Set(custom.map(\.id)).count == custom.count,
Set(custom.map(\.rule)).count == custom.count,
Set(disabled).count == disabled.count
else { throw CustomRuleStoreError.corrupt(detail: "Duplicate custom rule records") }
return CustomRuleDocument(rules: custom, disabledIdentities: Set(disabled))
}
}