mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 10:13:54 +00:00
Migrate legacy enabled fields to v3 without preserving their semantics. Keep recoverable invalid imports visible and filter them during activation.
121 lines
4.7 KiB
Swift
121 lines
4.7 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
|
|
/// 语义;旧字段由 CatalogEntry 解码时丢弃,下一次保存统一写出 v3。
|
|
private static let supportedVersions: Set<Int> = [1, 2, 3]
|
|
private static let currentVersion = 3
|
|
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`。
|
|
private struct CatalogFilePayload: Codable {
|
|
var version: Int
|
|
var rootChildren: [NodeID]
|
|
var entries: [CatalogEntry]
|
|
var subscriptions: [SubscriptionRecord]?
|
|
}
|