Files

164 lines
5.4 KiB
Swift

import Foundation
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)
}
/// Offline file adapter for maintainer-generated built-in snapshots.
struct RuleSnapshotStore {
static let currentSchemaVersion = 2
static let currentConverterVersion = "2.0.0"
let fileURL: URL
func load() throws -> RuleSnapshot {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw RuleSnapshotError.missing
}
let snapshot: RuleSnapshot
do {
snapshot = try Self.decode(Data(contentsOf: fileURL))
} catch let error as RuleSnapshotError {
throw error
} catch {
throw RuleSnapshotError.corrupt(detail: String(describing: error))
}
guard snapshot.metadata.converterVersion == Self.currentConverterVersion else {
throw RuleSnapshotError.converterVersionMismatch(
found: snapshot.metadata.converterVersion, expected: Self.currentConverterVersion)
}
try Self.validateRuleCount(snapshot.rules.count, source: snapshot.metadata.source.kind)
return snapshot
}
static func validateRuleCount(_ count: Int, source: RuleSourceKind) throws {
let maximum = source == .geolocationCN ? 200_000 : 50_000
guard (100...maximum).contains(count) else {
throw RuleSnapshotError.abnormalRuleCount(found: count, minimum: 100, maximum: maximum)
}
}
func save(_ snapshot: RuleSnapshot) throws {
try AtomicFileWriter.write(Self.encode(snapshot), to: fileURL)
}
static func decode(_ data: Data) throws -> RuleSnapshot {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let stored = try decoder.decode(StoredRuleSnapshot.self, from: data)
guard stored.schemaVersion == currentSchemaVersion else {
throw RuleSnapshotError.schemaVersionMismatch(
found: stored.schemaVersion, expected: currentSchemaVersion)
}
return try stored.domain()
}
static func encode(_ snapshot: RuleSnapshot) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(StoredRuleSnapshot(snapshot))
}
/// Preserve browsing's historical metadata fingerprints without making domain
/// models conform to the file format's encoding contract.
static func metadataTokens(for snapshot: RuleSnapshot) throws -> [String] {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return [
snapshot.metadata.source.kind.rawValue
+ ProxyACLDocument.digest(try encoder.encode(StoredSnapshotMetadata(snapshot.metadata))),
ProxyACLDocument.digest(try encoder.encode(StoredConversionReport(snapshot.lossReport))),
]
}
}
private struct StoredRuleSource: Codable {
let kind: String
let upstreamVersion: String
let label: String
init(_ source: RuleSourceIdentity) {
kind = source.kind.rawValue
upstreamVersion = source.upstreamVersion
label = source.label
}
func domain() throws -> RuleSourceIdentity {
guard let kind = RuleSourceKind(rawValue: kind) else {
throw RuleStorageDecodingError.invalidSource(kind)
}
return RuleSourceIdentity(kind: kind, upstreamVersion: upstreamVersion, label: label)
}
}
private struct StoredSnapshotMetadata: Codable {
let source: StoredRuleSource
let upstreamReference: String
let inputDigest: String
let fetchedAt: Date
let converterVersion: String
let license: String
let attribution: String
init(_ metadata: RuleSnapshotMetadata) {
source = StoredRuleSource(metadata.source)
upstreamReference = metadata.upstreamReference
inputDigest = metadata.inputDigest
fetchedAt = metadata.fetchedAt
converterVersion = metadata.converterVersion
license = metadata.license
attribution = metadata.attribution
}
func domain() throws -> RuleSnapshotMetadata {
RuleSnapshotMetadata(
source: try source.domain(), upstreamReference: upstreamReference, inputDigest: inputDigest,
fetchedAt: fetchedAt, converterVersion: converterVersion, license: license,
attribution: attribution)
}
}
private struct StoredConversionReport: Codable {
let convertedCount: Int
let absorbedCount: Int
let skipped: [String: Int]
let rejected: [String: Int]
init(_ report: RuleConversionLossReport) {
convertedCount = report.convertedCount
absorbedCount = report.absorbedCount
skipped = report.skipped
rejected = report.rejected
}
var domain: RuleConversionLossReport {
RuleConversionLossReport(
convertedCount: convertedCount, absorbedCount: absorbedCount, skipped: skipped,
rejected: rejected)
}
}
private struct StoredRuleSnapshot: Codable {
let schemaVersion: Int
let metadata: StoredSnapshotMetadata
let rules: [StoredProxyRule]
let lossReport: StoredConversionReport
init(_ snapshot: RuleSnapshot) {
schemaVersion = RuleSnapshotStore.currentSchemaVersion
metadata = StoredSnapshotMetadata(snapshot.metadata)
rules = snapshot.rules.map(StoredProxyRule.init)
lossReport = StoredConversionReport(snapshot.lossReport)
}
func domain() throws -> RuleSnapshot {
RuleSnapshot(
metadata: try metadata.domain(), rules: try rules.map { try $0.domain() },
lossReport: lossReport.domain)
}
}