diff --git a/GLOSSARY.md b/GLOSSARY.md index ad84b14a..a3bb5d2b 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -15,6 +15,9 @@ **配置组(Configuration group)**: 具有名称和子节点顺序的容器,可包含服务器配置和嵌套配置组;每个节点最多属于一个父节点,组之间不形成环。 +**节点时间戳(Node timestamps)**: +配置目录中每个节点(服务器配置或配置组)的创建时间与修改时间事实。创建时间是节点进入目录的时刻;修改时间是节点自身资料、或分组的直接子节点集合与顺序最后一次变化的时刻,移动节点本身不算修改。历史文档未记录的时间是未知事实,不做回填。 + **配置组导出(Configuration group export)**: 用户请求生成的可分享文件快照,包含一个配置组及其全部后代的连接资料和所需凭据。它可以包含不具备激活条件的服务器配置。 diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Duplicate.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Duplicate.swift index 6070a039..8131d4e7 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Duplicate.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Duplicate.swift @@ -38,17 +38,19 @@ extension CatalogWorkflow { return try commit { catalog in try copyEntry( entry, from: source, into: &catalog, parent: parent, - index: position + 1, name: name, journal: &journal) + index: position + 1, name: name, journal: &journal, now: Date()) } } catch { throw CommitError(underlying: error, credentialRollback: journal.rollback()) } } + /// 副本节点获得全新身份与全新时间戳(创建时间 = 复制时刻,ADR-0031); + /// 每个新建节点的落点分组按结构敏感语义更新修改时间。 private func copyEntry( _ entry: CatalogEntry, from source: ConfigurationCatalog, into catalog: inout ConfigurationCatalog, parent: NodeID?, index: Int? = nil, - name: String, journal: inout CredentialWriteJournal + name: String, journal: inout CredentialWriteJournal, now: Date ) throws -> NodeID { switch entry.kind { case .server(var fields): @@ -57,16 +59,16 @@ extension CatalogWorkflow { if let reference = fields.pluginOptionsRef { fields.pluginOptionsRef = try copyCredential(reference, journal: &journal) } - return try catalog.addServer(fields, to: parent, index: index) + return try catalog.addServer(fields, to: parent, index: index, now: now) case .group(let fields): - let copy = try catalog.addGroup(name, to: parent, index: index) + let copy = try catalog.addGroup(name, to: parent, index: index, now: now) for child in fields.children { guard let childEntry = source.entry(for: child) else { throw CatalogError.nodeNotFound(child) } _ = try copyEntry( childEntry, from: source, into: &catalog, parent: copy, - name: childEntry.displayName, journal: &journal) + name: childEntry.displayName, journal: &journal, now: now) } return copy } diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Import.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Import.swift index 044966ce..8c8ad522 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Import.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Import.swift @@ -83,8 +83,9 @@ extension CatalogWorkflow { } do { try commit { catalog in + let now = Date() for fields in prepared { - try catalog.addServer(fields, to: parent) + try catalog.addServer(fields, to: parent, now: now) } } } catch { @@ -137,9 +138,14 @@ extension CatalogWorkflow { var journal = CredentialWriteJournal(credentials: credentials) do { let rootID = try commit { catalog in - try Self.mountImportSnapshot( - snapshot, fileNameFallback: Self.fileNameBase(fileName), - into: &catalog, parent: parent, journal: &journal) + // 一次导入一个写入时刻:根分组与全部后代节点共用(ADR-0031)。 + let now = Date() + let base = snapshot.root.name.isEmpty ? Self.fileNameBase(fileName) : snapshot.root.name + let rootID = try catalog.addGroup( + Self.uniqueGroupName(base, in: catalog), to: parent, now: now) + try Self.mountSnapshotChildren( + of: snapshot.root, into: rootID, catalog: &catalog, journal: &journal, now: now) + return rootID } return .imported(count: Self.snapshotServerCount(snapshot.root), groupID: rootID) } catch { @@ -148,38 +154,25 @@ extension CatalogWorkflow { } } - /// 快照 → 手动子树:根分组整棵挂入选中落点,嵌套分组与服务器按快照顺序 - /// 重建。根组名取扩展根组名,缺失(扁平回退)时以文件名兜底,重名加序号。 - private static func mountImportSnapshot( - _ snapshot: SubscriptionSnapshot, - fileNameFallback: String, - into catalog: inout ConfigurationCatalog, - parent: NodeID?, - journal: inout CredentialWriteJournal - ) throws -> NodeID { - let base = snapshot.root.name.isEmpty ? fileNameFallback : snapshot.root.name - let rootID = try catalog.addGroup(uniqueGroupName(base, in: catalog), to: parent) - try mountSnapshotChildren( - of: snapshot.root, into: rootID, catalog: &catalog, journal: &journal) - return rootID - } - + /// 快照后代 → 手动子树:嵌套分组与服务器按快照顺序重建(根分组由调用方 + /// 挂载)。嵌套组缺名以占位名兜底。 private static func mountSnapshotChildren( of group: SubscriptionSnapshot.Group, into groupID: NodeID, catalog: inout ConfigurationCatalog, - journal: inout CredentialWriteJournal + journal: inout CredentialWriteJournal, + now: Date ) throws { for child in group.children { switch child { case .group(let nested): let nestedID = try catalog.addGroup( - nested.name.isEmpty ? "未命名分组" : nested.name, to: groupID) + nested.name.isEmpty ? "未命名分组" : nested.name, to: groupID, now: now) try mountSnapshotChildren( - of: nested, into: nestedID, catalog: &catalog, journal: &journal) + of: nested, into: nestedID, catalog: &catalog, journal: &journal, now: now) case .server(let leaf): try catalog.addServer( - importServerFields(from: leaf.record, journal: &journal), to: groupID) + importServerFields(from: leaf.record, journal: &journal), to: groupID, now: now) } } } diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift index 862bf791..4080e6b7 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift @@ -27,7 +27,7 @@ extension CatalogWorkflow { try commitSubscriptionDocument { catalog, subscriptions in // 固定分组挂在目录根(GLOSSARY.md「Subscription group」);名称以 host // 兜底,首次成功刷新后跟随远端。 - try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID) + try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID, now: Date()) subscriptions.append(record) } } catch { @@ -139,6 +139,7 @@ extension CatalogWorkflow { var credentialJournal = CredentialWriteJournal(credentials: dependencies.credentials) do { try commitSubscriptionDocument { catalog, subscriptions in + let now = Date() // 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用; // journal 保证快照提交失败时恢复旧秘密(story 29)。 var reusedRefs: [NodeID: ServerCredentialRefs] = [:] @@ -154,17 +155,17 @@ extension CatalogWorkflow { try Task.checkCancellation() let name = snapshot.root.name.isEmpty ? fallbackName : snapshot.root.name let document = CatalogSubscriptionSnapshot(name: name, root: resolved) - let replacedServers = try catalog.applySubscriptionSnapshot(document, into: summary.groupID) + let replacedServers = try catalog.applySubscriptionSnapshot( + document, into: summary.groupID, now: now) // 快照应用返回整个旧树;延续节点仍复用引用,不能按旧树直接删除秘密。 let retainedRefs = Set(Self.credentialRefs(of: Array(catalog.entries.values))) obsoleteCredentialRefs = Set(Self.credentialRefs(of: replacedServers)) .subtracting(retainedRefs) if let index = subscriptions.firstIndex(where: { $0.id == summary.id }) { - let succeededAt = Date() - subscriptions[index].status = .succeeded(at: succeededAt) + subscriptions[index].status = .succeeded(at: now) subscriptions[index].information = snapshot.information.isEmpty ? nil : snapshot.information - subscriptions[index].lastSucceededAt = succeededAt + subscriptions[index].lastSucceededAt = now } } } catch { diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift index a1e50d66..14af6d15 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift @@ -222,6 +222,7 @@ final class CatalogWorkflow: ObservableObject { var journal = CredentialWriteJournal(credentials: dependencies.credentials) do { return try commit { [self] catalog in + let now = Date() let passwordRef = CredentialReference.fresh() try journal.save(draft.password, for: passwordRef) var fields = ServerFields( @@ -231,7 +232,7 @@ final class CatalogWorkflow: ObservableObject { try Self.applyPluginSelection( draft.plugin, options: draft.pluginOptions, to: &fields, credentials: dependencies.credentials, journal: &journal) - return try catalog.addServer(fields, to: parent) + return try catalog.addServer(fields, to: parent, now: now) } } catch { throw CommitError(underlying: error, credentialRollback: journal.rollback()) @@ -244,7 +245,7 @@ final class CatalogWorkflow: ObservableObject { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { throw ServerFormError.emptyName } return try commit { catalog in - try catalog.addGroup(trimmed, to: parent) + try catalog.addGroup(trimmed, to: parent, now: Date()) } } @@ -253,7 +254,7 @@ final class CatalogWorkflow: ObservableObject { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { throw ServerFormError.emptyName } try commit { catalog in - try catalog.renameGroup(id, to: trimmed) + try catalog.renameGroup(id, to: trimmed, now: Date()) } } @@ -261,7 +262,7 @@ final class CatalogWorkflow: ObservableObject { /// typed error 原样上抛。 func move(_ id: NodeID, to parent: NodeID?) async throws { try commit { catalog in - try catalog.move(id, to: parent) + try catalog.move(id, to: parent, now: Date()) } } @@ -269,7 +270,7 @@ final class CatalogWorkflow: ObservableObject { /// Keychain 秘密;返回被删身份集合供 UI 清除失效选择(story 18)。 func remove(_ id: NodeID) async throws -> RemovalOutcome { let removed: [CatalogEntry] = try commit { catalog in - try catalog.remove(id) + try catalog.remove(id, now: Date()) } for ref in Self.credentialRefs(of: removed) { try? dependencies.credentials.delete(ref) @@ -297,7 +298,7 @@ final class CatalogWorkflow: ObservableObject { try Self.applyPluginSelection( draft.plugin, options: draft.pluginOptions, to: &fields, credentials: dependencies.credentials, journal: &journal) - try catalog.updateServer(id, with: fields) + try catalog.updateServer(id, with: fields, now: Date()) } serverUpdateSubject.send([id]) } catch { diff --git a/ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift b/ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift index bd73ca98..5828c0f4 100644 --- a/ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift +++ b/ShadowsocksX-NG2/Domain/Catalog/CatalogFileStore.swift @@ -13,10 +13,10 @@ struct CatalogFileStore { } /// v2 起携带订阅记录;v1(无订阅字段)仍可读取。v3 移除节点级 enabled - /// 语义;v4 把订阅失败字符串替换为 typed facts。读取兼容 v1–v4,写入 - /// 始终使用 v4。 - private static let supportedVersions: Set = [1, 2, 3, 4] - private static let currentVersion = 4 + /// 语义;v4 把订阅失败字符串替换为 typed facts;v5 增加节点创建/修改时间 + /// (v1–v4 读取为 nil 未知,不回填)。读取兼容 v1–v5,写入始终使用 v5。 + private static let supportedVersions: Set = [1, 2, 3, 4, 5] + private static let currentVersion = 5 private static let jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] @@ -111,7 +111,8 @@ struct CatalogFileStore { /// 落盘文档形态:版本号 + 根子序 + 全量节点表(顺序语义在根序与分组显子序中) /// + 订阅记录(v2 起携带;v1 缺省为空)。v3 节点条目不再写出 `enabled`; -/// v4 的失败状态只写 typed facts,不再写旧 `reason` 字段。 +/// v4 的失败状态只写 typed facts,不再写旧 `reason` 字段;v5 节点条目携带 +/// 创建/修改时间,未知(历史文档迁移节点)省略键。 private struct CatalogFilePayload: Codable { var version: Int var rootChildren: [NodeID] diff --git a/ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift b/ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift index ac6390de..2a6ccf01 100644 --- a/ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift +++ b/ShadowsocksX-NG2/Domain/Catalog/CatalogModels.swift @@ -53,7 +53,7 @@ struct GroupFields: Codable, Equatable, Sendable { } } -/// 目录树节点:公共字段 + 服务器叶子/分组形态。 +/// 目录树节点:公共字段(身份、来源、节点时间戳)+ 服务器叶子/分组形态。 struct CatalogEntry: Codable, Equatable, Sendable { enum Kind: Codable, Equatable, Sendable { case server(ServerFields) @@ -63,25 +63,34 @@ struct CatalogEntry: Codable, Equatable, Sendable { let id: NodeID var source: NodeSource var kind: Kind + /// 节点进入目录的时刻;历史文档未记录时为 nil(未知,不回填,ADR-0031)。 + let createdAt: Date? + /// 节点自身资料、或分组的直接子节点集合与顺序最后一次变化的时刻;未知为 nil。 + var updatedAt: Date? private enum CodingKeys: String, CodingKey { - case id, source, enabled, kind + case id, source, enabled, kind, createdAt, updatedAt } - init(id: NodeID, source: NodeSource, kind: Kind) { + init(id: NodeID, source: NodeSource, kind: Kind, createdAt: Date?, updatedAt: Date?) { self.id = id self.source = source self.kind = kind + self.createdAt = createdAt + self.updatedAt = updatedAt } /// Reads the removed `enabled` field from v1/v2 documents only for migration; - /// its value has no meaning in the current domain model. + /// its value has no meaning in the current domain model. Node timestamps are + /// absent in v1–v4 documents and decode as nil (unknown, never backfilled). init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(NodeID.self, forKey: .id) source = try container.decode(NodeSource.self, forKey: .source) _ = try container.decodeIfPresent(Bool.self, forKey: .enabled) kind = try container.decode(Kind.self, forKey: .kind) + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) } func encode(to encoder: Encoder) throws { @@ -89,6 +98,8 @@ struct CatalogEntry: Codable, Equatable, Sendable { try container.encode(id, forKey: .id) try container.encode(source, forKey: .source) try container.encode(kind, forKey: .kind) + try container.encodeIfPresent(createdAt, forKey: .createdAt) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) } } diff --git a/ShadowsocksX-NG2/Domain/Catalog/ConfigurationCatalog.swift b/ShadowsocksX-NG2/Domain/Catalog/ConfigurationCatalog.swift index 9688945e..f1ede21a 100644 --- a/ShadowsocksX-NG2/Domain/Catalog/ConfigurationCatalog.swift +++ b/ShadowsocksX-NG2/Domain/Catalog/ConfigurationCatalog.swift @@ -1,3 +1,5 @@ +import Foundation + /// 配置目录:不可见的配置树根(GLOSSARY.md「Configuration catalog」)。有序顶层 /// 子节点为服务器配置或配置组。存储形态:全量节点表 + 根序 + 各分组显子序; /// 单父由「一个身份只出现在一条子序里」构造保证,无环与来源分离由操作校验。 @@ -92,13 +94,16 @@ extension ConfigurationCatalog { // MARK: - 变更 extension ConfigurationCatalog { + /// 新建节点:创建时间与修改时间都是写入时刻(ADR-0031);落点分组的 + /// 直接子节点集合变化,按结构敏感语义更新该分组的修改时间。 @discardableResult mutating func addServer( _ fields: ServerFields, source: NodeSource = .manual, id proposedID: NodeID? = nil, to parent: NodeID? = nil, - index: Int? = nil + index: Int? = nil, + now: Date = Date() ) throws -> NodeID { let id = proposedID ?? .fresh() guard entries[id] == nil else { throw CatalogError.duplicateID(id) } @@ -106,7 +111,9 @@ extension ConfigurationCatalog { throw CatalogError.subscriptionServerAtRoot(id) } try place(id, source: source, parent: parent, index: index, excluding: nil) - entries[id] = CatalogEntry(id: id, source: source, kind: .server(fields)) + entries[id] = CatalogEntry( + id: id, source: source, kind: .server(fields), createdAt: now, updatedAt: now) + touchParent(of: id, at: now) return id } @@ -116,47 +123,66 @@ extension ConfigurationCatalog { source: NodeSource = .manual, id proposedID: NodeID? = nil, to parent: NodeID? = nil, - index: Int? = nil + index: Int? = nil, + now: Date = Date() ) throws -> NodeID { let id = proposedID ?? .fresh() guard entries[id] == nil else { throw CatalogError.duplicateID(id) } try place(id, source: source, parent: parent, index: index, excluding: nil) - entries[id] = CatalogEntry(id: id, source: source, kind: .group(GroupFields(name: name))) + entries[id] = CatalogEntry( + id: id, source: source, kind: .group(GroupFields(name: name)), + createdAt: now, updatedAt: now) + touchParent(of: id, at: now) return id } - mutating func renameGroup(_ id: NodeID, to name: String) throws { + /// 重命名分组:内容未变(同名)不更新修改时间。 + mutating func renameGroup(_ id: NodeID, to name: String, now: Date = Date()) throws { guard var entry = entries[id] else { throw CatalogError.nodeNotFound(id) } guard entry.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) } guard case .group(var fields) = entry.kind else { throw CatalogError.notAGroup(id) } + guard fields.name != name else { return } fields.name = name entry.kind = .group(fields) + entry.updatedAt = now entries[id] = entry } - mutating func updateServer(_ id: NodeID, with fields: ServerFields) throws { + /// 更新服务器连接字段:内容未变不更新修改时间。 + mutating func updateServer(_ id: NodeID, with fields: ServerFields, now: Date = Date()) throws { guard var entry = entries[id] else { throw CatalogError.nodeNotFound(id) } guard entry.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) } guard case .server = entry.kind else { throw CatalogError.notAServer(id) } - entry.kind = .server(fields) + let kind = CatalogEntry.Kind.server(fields) + guard entry.kind != kind else { return } + entry.kind = kind + entry.updatedAt = now entries[id] = entry } /// 移动节点(携带整棵子树):目录根与手动组之间、手动组相互之间。 - /// 同父移动即重排。跨来源、共享父、成环一律拒绝。 - mutating func move(_ id: NodeID, to parent: NodeID?, index: Int? = nil) throws { + /// 同父移动即重排。跨来源、共享父、成环一律拒绝。被移动节点自身内容不变、 + /// 修改时间不动;新旧落点分组的子节点集合/顺序变化,按结构敏感语义更新 + /// (根层无分组,静默)。 + mutating func move(_ id: NodeID, to parent: NodeID?, index: Int? = nil, now: Date = Date()) + throws + { guard let entry = entries[id] else { throw CatalogError.nodeNotFound(id) } guard entry.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) } if let parent, parent == id || ancestors(of: parent).contains(id) { throw CatalogError.cycleDetected(id) } + let oldParent = parentIndex()[id] try place(id, source: entry.source, parent: parent, index: index, excluding: id) + if oldParent != parent { touch(oldParent, at: now) } + touch(parent, at: now) } /// 删除节点;手动分组递归删除整棵子树。返回被删除的节点(含其凭据引用, /// 供调用方清理 Keychain 秘密)。空组允许显式删除;空组本身持久保留。 + /// 原落点分组失去子节点,按结构敏感语义更新其修改时间。 @discardableResult - mutating func remove(_ id: NodeID) throws -> [CatalogEntry] { + mutating func remove(_ id: NodeID, now: Date = Date()) throws -> [CatalogEntry] { guard let root = entries[id] else { throw CatalogError.nodeNotFound(id) } guard root.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) } @@ -168,7 +194,9 @@ extension ConfigurationCatalog { removed.append(entry) entries[current] = nil } + let oldParent = parentIndex()[id] removeFromSiblings(id) + touch(oldParent, at: now) return removed } @@ -254,10 +282,12 @@ extension ConfigurationCatalog { /// 订阅快照原子应用(GLOSSARY.md 刷新契约):以快照整体重建固定分组子树; /// 名称、结构、顺序和连接字段全部跟随远端(扩展缺失时由调用方给 URL host - /// 兜底)。返回被移除的旧服务器叶子(供调用方清理凭据)。 + /// 兜底)。返回被移除的旧服务器叶子(供调用方清理凭据)。延续节点按内容 + /// 比较决定时间戳:内容未变保留双时间戳,变化保留创建时间、更新修改时间; + /// 新节点两个时间戳都是写入时刻(ADR-0031)。 @discardableResult mutating func applySubscriptionSnapshot( - _ snapshot: CatalogSubscriptionSnapshot, into groupID: NodeID + _ snapshot: CatalogSubscriptionSnapshot, into groupID: NodeID, now: Date = Date() ) throws -> [CatalogEntry] { guard let fixed = entries[groupID], case .group = fixed.kind else { throw CatalogError.notAGroup(groupID) @@ -266,9 +296,11 @@ extension ConfigurationCatalog { throw CatalogError.crossSourcePlacement(node: .subscription, container: fixed.source) } - // 先整树摘除旧成员(固定分组本身保留),收集被移除的服务器。 + // 先整树摘除旧成员(固定分组本身保留),收集被移除的服务器;旧时间戳 + // 以条目载荷整体留底,重建时逐节点比较。 var removedServers: [CatalogEntry] = [] let oldSubtree = try subscriptionSubtree(of: groupID) + let previous = Dictionary(uniqueKeysWithValues: oldSubtree.map { ($0.id, $0) }) for entry in oldSubtree { if case .server = entry.kind { removedServers.append(entry) } } @@ -279,8 +311,10 @@ extension ConfigurationCatalog { // 远端权威重建:名称、结构、顺序、字段全按快照。 var fixedFields = GroupFields(name: snapshot.name, children: []) - try insertSnapshotChildren(of: snapshot.root, into: &fixedFields) - entries[groupID]?.kind = .group(fixedFields) + try insertSnapshotChildren(of: snapshot.root, into: &fixedFields, previous: previous, now: now) + entries[groupID] = Self.stampedEntry( + id: groupID, source: .subscription, kind: .group(fixedFields), + previous: previous, now: now) return removedServers } @@ -288,22 +322,27 @@ extension ConfigurationCatalog { /// 分组的显子序。身份已由解析器按订阅作用域限定,与既有节点冲突即程序错误。 private mutating func insertSnapshotChildren( of group: CatalogSubscriptionSnapshot.Group, - into fields: inout GroupFields + into fields: inout GroupFields, + previous: [NodeID: CatalogEntry], + now: Date ) throws { var childIDs: [NodeID] = [] for child in group.children { switch child { case .server(let leaf): guard entries[leaf.id] == nil else { throw CatalogError.duplicateID(leaf.id) } - entries[leaf.id] = CatalogEntry( - id: leaf.id, source: .subscription, kind: .server(leaf.fields)) + entries[leaf.id] = Self.stampedEntry( + id: leaf.id, source: .subscription, kind: .server(leaf.fields), + previous: previous, now: now) childIDs.append(leaf.id) case .group(let nested): guard entries[nested.id] == nil else { throw CatalogError.duplicateID(nested.id) } var nestedFields = GroupFields(name: nested.name, children: []) - try insertSnapshotChildren(of: nested, into: &nestedFields) - entries[nested.id] = CatalogEntry( - id: nested.id, source: .subscription, kind: .group(nestedFields)) + try insertSnapshotChildren( + of: nested, into: &nestedFields, previous: previous, now: now) + entries[nested.id] = Self.stampedEntry( + id: nested.id, source: .subscription, kind: .group(nestedFields), + previous: previous, now: now) childIDs.append(nested.id) } } @@ -312,6 +351,7 @@ extension ConfigurationCatalog { /// 删除订阅:固定分组整棵子树连同固定分组本身一并移除(递归清除,不留 /// 墓碑)。返回被删条目(含凭据引用,供调用方清理 Keychain 秘密)。 + /// 固定分组的原落点(目录根)无分组形态,无修改时间可更新。 @discardableResult mutating func removeSubscriptionSubtree(of groupID: NodeID) throws -> [CatalogEntry] { let subtree = try subscriptionSubtree(of: groupID) @@ -321,4 +361,36 @@ extension ConfigurationCatalog { removeFromSiblings(groupID) return subtree } + + /// 按内容比较决定时间戳的条目构造(ADR-0031):延续节点内容未变保留双 + /// 时间戳,变化保留创建时间、更新修改时间;新节点双时间戳为写入时刻。 + private static func stampedEntry( + id: NodeID, + source: NodeSource, + kind: CatalogEntry.Kind, + previous: [NodeID: CatalogEntry], + now: Date + ) -> CatalogEntry { + guard let old = previous[id] else { + return CatalogEntry(id: id, source: source, kind: kind, createdAt: now, updatedAt: now) + } + let updatedAt = old.kind == kind ? old.updatedAt : now + return CatalogEntry( + id: id, source: source, kind: kind, createdAt: old.createdAt, updatedAt: updatedAt) + } +} + +// MARK: - 节点时间戳(ADR-0031) + +extension ConfigurationCatalog { + /// 更新节点修改时间;节点不存在或根层(nil)静默无操作。 + private mutating func touch(_ id: NodeID?, at now: Date) { + guard let id, entries[id] != nil else { return } + entries[id]?.updatedAt = now + } + + /// 子节点集合或顺序变化后更新其所在分组(类目录修改时间语义);根层无分组。 + private mutating func touchParent(of child: NodeID, at now: Date) { + touch(parentIndex()[child], at: now) + } } diff --git a/ShadowsocksX-NG2/Domain/LegacyImport/LegacyImport.swift b/ShadowsocksX-NG2/Domain/LegacyImport/LegacyImport.swift index f4635040..5df7ab63 100644 --- a/ShadowsocksX-NG2/Domain/LegacyImport/LegacyImport.swift +++ b/ShadowsocksX-NG2/Domain/LegacyImport/LegacyImport.swift @@ -186,9 +186,11 @@ struct LegacyImportPlanner { existingDocument: CatalogDocument ) throws -> LegacyImportPlan { var catalog = existingDocument.catalog + let now = Date() let groupName = nextGroupName(in: catalog) - let groupID = try catalog.addGroup(groupName) - let serverImport = try importServers(snapshot.profiles, into: &catalog, groupID: groupID) + let groupID = try catalog.addGroup(groupName, now: now) + let serverImport = try importServers( + snapshot.profiles, into: &catalog, groupID: groupID, now: now) let report = LegacyImportReport( groupName: groupName, importedServerCount: serverImport.importedServerCount, @@ -227,7 +229,8 @@ extension LegacyImportPlanner { private static func importServers( _ profiles: [LegacyServerSnapshot], into catalog: inout ConfigurationCatalog, - groupID: NodeID + groupID: NodeID, + now: Date ) throws -> ServerImportResult { var credentials: [CredentialReference: String] = [:] var skippedRecords: [LegacySkippedRecord] = [] @@ -260,7 +263,7 @@ extension LegacyImportPlanner { remark: profile.remark ?? "", pluginProgram: profile.plugin?.isEmpty == false ? profile.plugin : nil, pluginOptionsRef: pluginOptionsRef) - try catalog.addServer(fields, id: nodeID, to: groupID) + try catalog.addServer(fields, id: nodeID, to: groupID, now: now) importedServerCount += 1 } } diff --git a/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift b/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift index ae22344f..439198b2 100644 --- a/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift +++ b/ShadowsocksX-NG2/Tests/Catalog/CatalogFileStoreTests.swift @@ -140,7 +140,7 @@ final class CatalogFileStoreTests: XCTestCase { "订阅 URL 明文不得落盘(D5:只存凭据引用)") } - func testV3FailureReasonMigratesToLegacyAndV4SaveRemovesReason() throws { + func testV3FailureReasonMigratesToLegacyAndNextSaveRemovesReason() throws { let fixture = try CatalogFixtures.makeSubscriptionFixture(prefix: "migration") let record = SubscriptionRecord( id: NodeID(rawValue: "migration:source"), @@ -173,7 +173,7 @@ final class CatalogFileStoreTests: XCTestCase { try store.save(loaded) let raw = try String(contentsOf: store.fileURL, encoding: .utf8) - XCTAssertTrue(raw.contains("\"version\" : 4")) + XCTAssertTrue(raw.contains("\"version\" : 5")) XCTAssertFalse(raw.contains("\"reason\"")) XCTAssertFalse(raw.contains("provider.example")) } @@ -190,7 +190,7 @@ final class CatalogFileStoreTests: XCTestCase { XCTAssertTrue(loaded.catalog.isEmpty) } - func testLegacyEnabledFieldIsIgnoredAndNextSaveWritesV4WithoutIt() throws { + func testLegacyEnabledFieldIsIgnoredAndNextSaveWritesCurrentVersionWithoutIt() throws { let payload = """ {"version": 1, "rootChildren": ["g"], "entries": [ {"id": "g", "source": "manual", "enabled": false, @@ -203,8 +203,49 @@ final class CatalogFileStoreTests: XCTestCase { try store.save(loaded) let raw = try String(contentsOf: store.fileURL, encoding: .utf8) - XCTAssertTrue(raw.contains("\"version\" : 4")) - XCTAssertFalse(raw.contains("\"enabled\""), "v4 不再写出节点级 enabled") + XCTAssertTrue(raw.contains("\"version\" : 5")) + XCTAssertFalse(raw.contains("\"enabled\""), "v3 起不再写出节点级 enabled") + } + + // MARK: 节点时间戳(v5,ADR-0031) + + func testNodeTimestampsRoundTripLosslessly() throws { + var catalog = ConfigurationCatalog() + let created = Date(timeIntervalSince1970: 1_700_000_000) + let id = try catalog.addServer( + ServerFields( + address: "198.51.100.9", + port: 8388, + encryptionMethod: "aes-256-gcm", + passwordRef: CredentialReference(rawValue: "ref-timestamps"), + remark: "时间戳" + ), + id: NodeID(rawValue: "manual:timestamped"), + now: created + ) + + try store.save(CatalogDocument(catalog: catalog)) + let loaded = try store.load() + + let entry = try XCTUnwrap(loaded.catalog.entry(for: id)) + XCTAssertEqual(entry.createdAt, created, "创建时间无损") + XCTAssertEqual(entry.updatedAt, created) + } + + func testLegacyEntriesLoadWithUnknownTimestampsInsteadOfBackfill() throws { + let payload = """ + {"version": 4, "rootChildren": ["g"], "entries": [ + {"id": "g", "source": "manual", + "kind": {"group": {"_0": {"name": "G", "children": []}}}} + ]} + """ + try payload.write(to: store.fileURL, atomically: true, encoding: .utf8) + + let loaded = try store.load() + + let group = try XCTUnwrap(loaded.catalog.entry(for: NodeID(rawValue: "g"))) + XCTAssertNil(group.createdAt, "历史文档缺时间戳读取为未知,不回填") + XCTAssertNil(group.updatedAt) } /// 订阅记录 JSON 用真编码器生成:枚举 Codable 形状是实现细节,不在测试里手写。 diff --git a/ShadowsocksX-NG2/Tests/Catalog/ConfigurationCatalogTimestampTests.swift b/ShadowsocksX-NG2/Tests/Catalog/ConfigurationCatalogTimestampTests.swift new file mode 100644 index 00000000..125613e7 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Catalog/ConfigurationCatalogTimestampTests.swift @@ -0,0 +1,228 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 节点时间戳语义(ADR-0031):节点自身资料变化更新自身修改时间;分组的 +/// 直接子节点集合或顺序变化更新该分组(类目录修改时间);被移动节点自身 +/// 不动;内容未变不更新。订阅刷新按节点内容逐项比较。 +final class ConfigurationCatalogTimestampTests: XCTestCase { + private let time0 = Date(timeIntervalSince1970: 1_700_000_000) + private let time1 = Date(timeIntervalSince1970: 1_700_001_000) + private let time2 = Date(timeIntervalSince1970: 1_700_002_000) + + // MARK: 手动子树 + + func testNewNodeStampsBothTimesAndBumpsParentGroup() throws { + var catalog = ConfigurationCatalog() + let group = try catalog.addGroup("组", now: time0) + let leaf = try catalog.addTestServer("a", to: group, now: time1) + + let groupEntry = try XCTUnwrap(catalog.entry(for: group)) + let leafEntry = try XCTUnwrap(catalog.entry(for: leaf)) + XCTAssertEqual(groupEntry.createdAt, time0) + XCTAssertEqual(groupEntry.updatedAt, time1, "新增子节点更新落点分组修改时间") + XCTAssertEqual(leafEntry.createdAt, time1) + XCTAssertEqual(leafEntry.updatedAt, time1) + } + + func testUpdateServerBumpsOnlyWhenContentChanges() throws { + var catalog = ConfigurationCatalog() + let id = try catalog.addTestServer("a", now: time0) + guard case .server(var fields)? = catalog.entry(for: id)?.kind else { + return XCTFail("应为服务器叶子") + } + + fields.port = 9999 + try catalog.updateServer(id, with: fields, now: time1) + XCTAssertEqual(catalog.entry(for: id)?.updatedAt, time1, "内容变化更新修改时间") + XCTAssertEqual(catalog.entry(for: id)?.createdAt, time0, "创建时间保留") + + try catalog.updateServer(id, with: fields, now: time2) + XCTAssertEqual(catalog.entry(for: id)?.updatedAt, time1, "内容未变不更新修改时间") + } + + func testRenameGroupBumpsOnlyWhenNameChanges() throws { + var catalog = ConfigurationCatalog() + let group = try catalog.addGroup("旧名", now: time0) + + try catalog.renameGroup(group, to: "旧名", now: time1) + XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time0, "同名重命名是内容未变") + + try catalog.renameGroup(group, to: "新名", now: time2) + XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time2) + XCTAssertEqual(catalog.entry(for: group)?.createdAt, time0) + } + + func testMoveBumpsSourceAndTargetGroupsButNotMovedNode() throws { + var catalog = ConfigurationCatalog() + let source = try catalog.addGroup("源组", now: time0) + let target = try catalog.addGroup("目标组", now: time0) + let leaf = try catalog.addTestServer("a", to: source, now: time0) + + try catalog.move(leaf, to: target, now: time1) + + XCTAssertEqual(catalog.entry(for: leaf)?.updatedAt, time0, "被移动节点自身内容不变") + XCTAssertEqual(catalog.entry(for: source)?.updatedAt, time1, "原分组失去子节点") + XCTAssertEqual(catalog.entry(for: target)?.updatedAt, time1, "目标分组获得子节点") + } + + func testReorderBumpsParentOnly() throws { + var catalog = ConfigurationCatalog() + let group = try catalog.addGroup("组", now: time0) + let first = try catalog.addTestServer("a", to: group, now: time0) + let second = try catalog.addTestServer("b", to: group, now: time0) + + try catalog.move(second, to: group, index: 0, now: time1) + + XCTAssertEqual(try catalog.children(of: group), [second, first]) + XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time1, "直接子序变化更新分组") + XCTAssertEqual(catalog.entry(for: first)?.updatedAt, time0) + XCTAssertEqual(catalog.entry(for: second)?.updatedAt, time0) + } + + func testRemoveBumpsParentGroupOfRemovedRoot() throws { + var catalog = ConfigurationCatalog() + let group = try catalog.addGroup("组", now: time0) + let leaf = try catalog.addTestServer("a", to: group, now: time0) + + _ = try catalog.remove(leaf, now: time1) + + XCTAssertFalse(catalog.contains(leaf)) + XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time1, "分组失去子节点") + } + + func testRootLevelChangesDoNotTouchAnyTimestamp() throws { + var catalog = ConfigurationCatalog() + let first = try catalog.addTestServer("a", now: time0) + let second = try catalog.addTestServer("b", now: time1) + + _ = try catalog.remove(first, now: time2) + + XCTAssertEqual(catalog.entry(for: second)?.updatedAt, time1, "根层结构变化无分组可更新") + } + + // MARK: 订阅快照刷新 + + private let groupID = NodeID(rawValue: "t:group") + private let nestedID = NodeID(rawValue: "t:nested") + private let leafA = NodeID(rawValue: "t:a") + private let leafB = NodeID(rawValue: "t:b") + + private func fields(remark: String, port: Int = 8388) -> ServerFields { + ServerFields( + address: "203.0.113.7", + port: port, + encryptionMethod: "aes-256-gcm", + passwordRef: CredentialReference(rawValue: "ref-\(remark)"), + remark: remark + ) + } + + /// 固定分组(嵌套分组 + 两个服务器叶子)与快照同构的目录夹具。 + private func makeSubscriptionCatalog() throws -> ConfigurationCatalog { + var catalog = ConfigurationCatalog() + try catalog.addGroup("订阅分组", source: .subscription, id: groupID, now: time0) + try catalog.addGroup("嵌套分组", source: .subscription, id: nestedID, to: groupID, now: time0) + try catalog.addServer( + fields(remark: "a"), source: .subscription, id: leafA, to: groupID, now: time0) + try catalog.addServer( + fields(remark: "b"), source: .subscription, id: leafB, to: nestedID, now: time0) + return catalog + } + + /// 与夹具同构的快照(固定分组 = 嵌套分组 + 服务器 a;嵌套 = 服务器 b), + /// 各参数可单独改写以制造远端变化。 + private func snapshot( + name: String = "订阅分组", + nestedName: String = "嵌套分组", + nestedChildren: [CatalogSubscriptionSnapshot.Child] + ) -> CatalogSubscriptionSnapshot { + let nested = CatalogSubscriptionSnapshot.Group( + id: nestedID, name: nestedName, children: nestedChildren) + return CatalogSubscriptionSnapshot( + name: name, + root: CatalogSubscriptionSnapshot.Group( + id: NodeID(rawValue: "t:remote-root"), name: name, + children: [ + CatalogSubscriptionSnapshot.Child.group(nested), + leaf(leafA, fields(remark: "a")), + ])) + } + + private func leaf( + _ id: NodeID, _ leafFields: ServerFields + ) -> CatalogSubscriptionSnapshot.Child { + .server(.init(id: id, fields: leafFields)) + } + + func testIdenticalSnapshotRefreshKeepsEveryTimestamp() throws { + var catalog = try makeSubscriptionCatalog() + let before = catalog + let snap = snapshot(nestedChildren: [leaf(leafB, fields(remark: "b"))]) + + _ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1) + + XCTAssertEqual(catalog, before, "内容未变的刷新不动任何节点的时间戳") + } + + func testChangedLeafBumpsOnlyThatLeaf() throws { + var catalog = try makeSubscriptionCatalog() + let snap = snapshot(nestedChildren: [leaf(leafB, fields(remark: "b", port: 9999))]) + + _ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1) + + XCTAssertEqual(catalog.entry(for: leafB)?.updatedAt, time1, "内容变化的叶子更新修改时间") + XCTAssertEqual(catalog.entry(for: leafB)?.createdAt, time0, "延续叶子保留创建时间") + XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time0, "嵌套分组名称与直接子序未变") + XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0, "固定分组名称与直接子序未变") + } + + func testNewLeafStampsFreshTimesAndBumpsItsParentGroup() throws { + var catalog = try makeSubscriptionCatalog() + let leafC = NodeID(rawValue: "t:c") + let snap = snapshot( + nestedChildren: [leaf(leafB, fields(remark: "b")), leaf(leafC, fields(remark: "c"))]) + + _ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1) + + XCTAssertEqual(catalog.entry(for: leafC)?.createdAt, time1) + XCTAssertEqual(catalog.entry(for: leafC)?.updatedAt, time1) + XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time1, "嵌套分组直接子节点集合变化") + XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0, "固定分组直接子序未变") + } + + func testRemoteRenameBumpsOnlyRenamedGroup() throws { + var catalog = try makeSubscriptionCatalog() + let snap = snapshot( + nestedName: "远端新名", nestedChildren: [leaf(leafB, fields(remark: "b"))]) + + _ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1) + + XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time1) + XCTAssertEqual(catalog.entry(for: leafB)?.updatedAt, time0) + XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0, "固定分组自身名称未变") + } + + func testRemoteFixedGroupNameChangeBumpsFixedGroup() throws { + var catalog = try makeSubscriptionCatalog() + let snap = snapshot( + name: "远端新订阅名", nestedChildren: [leaf(leafB, fields(remark: "b"))]) + + _ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1) + + XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time1) + XCTAssertEqual(catalog.entry(for: groupID)?.createdAt, time0, "固定分组身份客户端所有,创建时间保留") + XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time0) + } + + func testRemovedLeafBumpsItsParentGroup() throws { + var catalog = try makeSubscriptionCatalog() + let snap = snapshot(nestedChildren: []) + + _ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1) + + XCTAssertFalse(catalog.contains(leafB)) + XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time1, "子节点被远端移除") + XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0) + } +} diff --git a/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift b/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift index 914aabb6..3af17fe9 100644 --- a/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift +++ b/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift @@ -78,10 +78,12 @@ extension ConfigurationCatalog { source: NodeSource = .manual, id: NodeID? = nil, to parent: NodeID? = nil, - index: Int? = nil + index: Int? = nil, + now: Date = Date() ) throws -> NodeID { try addServer( - CatalogFixtures.serverFields(remark: remark), source: source, id: id, to: parent, index: index + CatalogFixtures.serverFields(remark: remark), source: source, id: id, to: parent, + index: index, now: now ) } } diff --git a/docs/adr/0031-node-creation-and-modification-timestamps.md b/docs/adr/0031-node-creation-and-modification-timestamps.md new file mode 100644 index 00000000..591a9f45 --- /dev/null +++ b/docs/adr/0031-node-creation-and-modification-timestamps.md @@ -0,0 +1,17 @@ +# 配置目录节点记录创建时间与修改时间 + +Status: Accepted (2026-10-09) + +配置目录的每个节点(服务器配置与配置组)增加创建时间与修改时间事实,目录文档版本升至 v5(读取兼容 v1–v5,写入始终 v5)。修改时间采用结构敏感语义(类比目录 mtime):节点自身资料变化(服务器的连接字段与名称、分组的名称)更新自身;分组的直接子节点集合或顺序变化(增删子节点、子节点移入移出、重排)更新该分组;被移动节点自身内容不变、修改时间不动;内容未变的写操作(同名重命名、同值字段提交、内容未变的订阅刷新)不更新任何时间戳。时间一律由调用方传入(`now: Date`),领域层保持无时钟纯值类型;一次逻辑变更(导入、复制、订阅快照应用)共享同一时刻。 + +订阅子树同样维护时间戳:订阅刷新以快照整体重建,但逐节点与旧载荷比较——延续且内容未变的节点保留双时间戳,内容变化的保留创建时间、更新修改时间,新出现的节点双时间戳为写入时刻;固定分组身份客户端所有,其创建时间跨刷新保留。复制、URI/文件导入与 Legacy 导入产生的新节点,创建时间即操作时刻,与「全新本地身份」规则一致(ADR-0027)。历史文档(v1–v4)没有时间戳,读取为未知(nil)且不回填,与订阅 `lastSucceededAt` 的「旧文件缺失时不猜测」原则一致。 + +## Considered options + +- 最窄语义(仅节点自身资料变化才更新,结构变化完全不动时间戳):实现最简,但把服务器移入/移出分组、组内重排不会在任何节点留下痕迹,与「分组内容被改过」的用户直觉不符。被否。 +- 写入即更新(不比较内容,任何写操作都刷新相关节点):无需比较逻辑,但内容未变的订阅刷新会刷新整棵子树的修改时间,同名重命名与同值保存也会伪造变化,事实失真。被否。 + +## Consequences + +- v5 文档中未知的时间戳省略键;`CatalogEntry` 的 `Equatable` 包含时间戳,跨时间构造的目录夹具做整体相等比较时必须用受控时刻。 +- 展示 UI 不在本次范围内;后续直接读取节点的创建/修改时间即可,语义以本 ADR 与 GLOSSARY「节点时间戳」为准。