mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 10:13:54 +00:00
123 lines
4.8 KiB
Swift
123 lines
4.8 KiB
Swift
import Foundation
|
||
|
||
/// 配置树磁盘持久化(spec #21 D5):落盘 `~/Library/Application Support/
|
||
/// ShadowsocksX-NG2/catalog.json`,目录权限 0700、文件 0600;写临时文件
|
||
/// (创建即 0600)后原子替换。文件只含结构化目录、凭据引用与订阅元数据
|
||
/// (URL 仍是凭据引用),永不落秘密明文。
|
||
struct CatalogFileStore {
|
||
enum PersistenceError: Error, Equatable {
|
||
/// 文件存在但不是本模型可接受的目录文档:JSON 损坏、版本未知、结构不一致。
|
||
case corrupt(detail: String)
|
||
/// 读取或写入时的文件系统错误。
|
||
case ioFailure(detail: String)
|
||
}
|
||
|
||
/// v2 起携带订阅记录;v1(无订阅字段)仍可读取。v3 移除节点级 enabled
|
||
/// 语义;v4 把订阅失败字符串替换为 typed facts。读取兼容 v1–v4,写入
|
||
/// 始终使用 v4。
|
||
private static let supportedVersions: Set<Int> = [1, 2, 3, 4]
|
||
private static let currentVersion = 4
|
||
private static let jsonEncoder: JSONEncoder = {
|
||
let encoder = JSONEncoder()
|
||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||
return encoder
|
||
}()
|
||
|
||
let fileURL: URL
|
||
|
||
/// 默认位置:`~/Library/Application Support/ShadowsocksX-NG2/catalog.json`。
|
||
static func defaultFileURL() -> URL {
|
||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[
|
||
0]
|
||
return support.appendingPathComponent("ShadowsocksX-NG2/catalog.json")
|
||
}
|
||
|
||
/// 文件缺失 → 全新空文档;存在但损坏/版本未知/结构不一致 → `.corrupt`。
|
||
func load() throws -> CatalogDocument {
|
||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||
return CatalogDocument()
|
||
}
|
||
let data: Data
|
||
do {
|
||
data = try Data(contentsOf: fileURL)
|
||
} catch {
|
||
throw PersistenceError.ioFailure(detail: String(describing: error))
|
||
}
|
||
return try decode(data)
|
||
}
|
||
|
||
/// 整体重写并原子替换;失败时保留原文件。目录按 D5 基线强制 0700(含自愈)。
|
||
func save(_ document: CatalogDocument) throws {
|
||
let payload = CatalogFilePayload(
|
||
version: Self.currentVersion,
|
||
rootChildren: document.catalog.rootChildren,
|
||
entries: document.catalog.entries.values.sorted { $0.id.rawValue < $1.id.rawValue },
|
||
subscriptions: document.subscriptions)
|
||
let data: Data
|
||
do {
|
||
data = try Self.jsonEncoder.encode(payload)
|
||
} catch {
|
||
throw PersistenceError.ioFailure(detail: String(describing: error))
|
||
}
|
||
do {
|
||
try AtomicFileWriter.write(data, to: fileURL)
|
||
} catch {
|
||
throw PersistenceError.ioFailure(detail: String(describing: error))
|
||
}
|
||
}
|
||
|
||
private func decode(_ data: Data) throws -> CatalogDocument {
|
||
let payload: CatalogFilePayload
|
||
do {
|
||
payload = try JSONDecoder().decode(CatalogFilePayload.self, from: data)
|
||
} catch {
|
||
throw PersistenceError.corrupt(detail: String(describing: error))
|
||
}
|
||
guard Self.supportedVersions.contains(payload.version) else {
|
||
throw PersistenceError.corrupt(detail: "unsupported version \(payload.version)")
|
||
}
|
||
var entries: [NodeID: CatalogEntry] = [:]
|
||
for entry in payload.entries {
|
||
guard entries[entry.id] == nil else {
|
||
throw PersistenceError.corrupt(detail: "duplicate node id \(entry.id.rawValue)")
|
||
}
|
||
entries[entry.id] = entry
|
||
}
|
||
let catalog: ConfigurationCatalog
|
||
do {
|
||
catalog = try ConfigurationCatalog.validated(
|
||
rootChildren: payload.rootChildren, entries: entries)
|
||
} catch let error as CatalogError {
|
||
throw PersistenceError.corrupt(detail: String(describing: error))
|
||
}
|
||
let subscriptions = payload.subscriptions ?? []
|
||
var seen = Set<NodeID>()
|
||
for record in subscriptions {
|
||
guard seen.insert(record.id).inserted else {
|
||
throw PersistenceError.corrupt(detail: "duplicate subscription \(record.id.rawValue)")
|
||
}
|
||
}
|
||
// 订阅固定分组必须存在于目录且来源正确;缺失即文档不一致。
|
||
for record in subscriptions {
|
||
guard
|
||
let entry = catalog.entry(for: record.groupID), case .group = entry.kind,
|
||
entry.source == .subscription
|
||
else {
|
||
throw PersistenceError.corrupt(
|
||
detail: "subscription group missing \(record.groupID.rawValue)")
|
||
}
|
||
}
|
||
return CatalogDocument(catalog: catalog, subscriptions: subscriptions)
|
||
}
|
||
}
|
||
|
||
/// 落盘文档形态:版本号 + 根子序 + 全量节点表(顺序语义在根序与分组显子序中)
|
||
/// + 订阅记录(v2 起携带;v1 缺省为空)。v3 节点条目不再写出 `enabled`;
|
||
/// v4 的失败状态只写 typed facts,不再写旧 `reason` 字段。
|
||
private struct CatalogFilePayload: Codable {
|
||
var version: Int
|
||
var rootChildren: [NodeID]
|
||
var entries: [CatalogEntry]
|
||
var subscriptions: [SubscriptionRecord]?
|
||
}
|