Files
Qiu Yuzhou f9e95474e5 refactor: adopt age configuration storage
Replace the custom encrypted container with AgeSwift 0.1.0 so standard
age tools can inspect sensitive configuration independently of the app.

Use independent Keychain X25519 identities for catalog.age and
proxy-listening.age. Preserve atomic commits and confirmed recovery;
reject historical NG2 formats without migration or cleanup.
2026-10-21 14:31:32 +08:00

132 lines
4.6 KiB
Swift

import Foundation
/// 服务器库文件存储;生产和测试均通过整体加密存储写盘。
struct CatalogFileStore {
enum PersistenceError: Error, Equatable {
/// 文件存在但不是本模型可接受的目录文档:JSON 损坏、版本未知、结构不一致。
case corrupt(detail: String)
/// 读取或写入时的文件系统错误。
case ioFailure(detail: String)
}
private static let currentVersion = 7
private static let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}()
let fileURL: URL
private let library: ServerLibraryStorage
init(fileURL: URL, library: ServerLibraryStorage = ServerLibraryStorage()) {
self.fileURL = fileURL
self.library = library
}
func reset() throws -> CatalogDocument {
try library.reset(at: fileURL)
}
/// 默认密文位置:`~/Library/Application Support/ShadowsocksX-NG2/configuration/catalog.age`。
static func defaultFileURL() -> URL {
ApplicationSupportPaths.current.catalogFileURL
}
/// 文件缺失 → 全新空文档;存在但损坏/版本未知/结构不一致 → `.corrupt`。
func load() throws -> CatalogDocument {
try load(allowingInteraction: false)
}
func load(allowingInteraction: Bool) throws -> CatalogDocument {
try library.load(at: fileURL, allowingInteraction: allowingInteraction)
}
/// 整体加密后原子替换,失败时保留原文件。
func save(_ document: CatalogDocument) throws {
try library.save(document, at: fileURL)
}
static func encode(_ document: CatalogDocument) throws -> Data {
try Self.validateFavorites(document.favoriteIDs, in: document.catalog)
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, favoriteIDs: document.favoriteIDs)
let data: Data
do {
data = try Self.jsonEncoder.encode(payload)
} catch {
throw PersistenceError.ioFailure(detail: String(describing: error))
}
return data
}
static 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 payload.version == Self.currentVersion 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)")
}
}
let favoriteIDs = payload.favoriteIDs
try validateFavorites(favoriteIDs, in: catalog)
return CatalogDocument(
catalog: catalog, subscriptions: subscriptions, favoriteIDs: favoriteIDs)
}
private static func validateFavorites(_ ids: [NodeID], in catalog: ConfigurationCatalog) throws {
var seen = Set<NodeID>()
for id in ids {
guard seen.insert(id).inserted, catalog.entry(for: id) != nil else {
throw PersistenceError.corrupt(detail: "duplicate or missing favorite node \(id.rawValue)")
}
}
}
}
/// 当前服务器库明文载荷;只能在内存中编解码,由整体加密存储写盘。
private struct CatalogFilePayload: Codable {
var version: Int
var rootChildren: [NodeID]
var entries: [CatalogEntry]
var subscriptions: [SubscriptionRecord]
var favoriteIDs: [NodeID]
}