Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/CatalogFileStore.swift
T
Qiu Yuzhou ea801d5f38 feat(catalog): remove node enable state
Migrate legacy enabled fields to v3 without preserving their semantics.
Keep recoverable invalid imports visible and filter them during activation.
2026-09-22 08:40:13 +08:00

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]?
}