Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/CatalogFileStore.swift
T

123 lines
4.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]?
}