From 3b3528d2320e8a90af4262e3a8c4e8e2f1c987fa Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 5 Oct 2026 00:10:13 +0800 Subject: [PATCH] feat(subscription): show usage and provider dates --- GLOSSARY.md | 3 + .../CatalogWorkflow+Subscriptions.swift | 15 +- .../CatalogWorkflowModels.swift | 2 + ShadowsocksX-NG2/App/Localizable.xcstrings | 66 +++++ .../SubscriptionInformationPresentation.swift | 18 ++ ShadowsocksX-NG2/App/SubscriptionsView.swift | 63 +++- .../Domain/SubscriptionDocument.swift | 8 +- .../Domain/SubscriptionInformation.swift | 150 ++++++++++ .../Domain/SubscriptionRecord.swift | 4 + ShadowsocksX-NG2/README.md | 8 + .../SubscriptionInformationParserTests.swift | 116 ++++++++ ...criptionInformationPresentationTests.swift | 24 ++ ...SubscriptionInformationWorkflowTests.swift | 273 ++++++++++++++++++ 13 files changed, 736 insertions(+), 14 deletions(-) create mode 100644 ShadowsocksX-NG2/App/SubscriptionInformationPresentation.swift create mode 100644 ShadowsocksX-NG2/Domain/SubscriptionInformation.swift create mode 100644 ShadowsocksX-NG2/Tests/SubscriptionInformationParserTests.swift create mode 100644 ShadowsocksX-NG2/Tests/SubscriptionInformationPresentationTests.swift create mode 100644 ShadowsocksX-NG2/Tests/SubscriptionInformationWorkflowTests.swift diff --git a/GLOSSARY.md b/GLOSSARY.md index 8b33ea8d..848009b7 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -40,6 +40,9 @@ _避免用词_:导出(导出指配置组或诊断报告等文件快照)。 **订阅(Subscription)**: 具有持久身份的远程配置来源,对应一个固定的订阅组;订阅 URL 是可修改的访问地址,而非来源身份。 +**订阅资料(Subscription information)**: +供应商随订阅提供的已用流量、剩余流量、服务到期时间与流量重置时间等事实。它不包含服务器连接资料、凭据或完整订阅响应。 + **订阅组(Subscription group)**: 一个订阅拥有的配置子树,内容和结构以远程资料为准,用户不能在本地覆盖其成员、名称或连接字段。 diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift index eb08da75..862bf791 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift @@ -56,6 +56,7 @@ extension CatalogWorkflow { } catch is CancellationError { // 取消不构成失败:快照与状态都不动。 } catch { + guard !Task.isCancelled else { return } let result = Self.refreshFailureResult(from: error) setSubscriptionRefreshFailure(result, for: id) await markRefreshFailed(subscriptionID: id, failure: result.failure) @@ -120,6 +121,7 @@ extension CatalogWorkflow { let urlString = try subscriptionURL(for: record.id) let url = try Self.validatedSubscriptionURL(urlString) let data = try await dependencies.subscriptionFetcher.fetch(url) + try Task.checkCancellation() let snapshot = try SubscriptionDocumentParser.parse(data, subscriptionID: record.id) let summary = subscriptions.first(where: { $0.id == record.id }) @@ -132,6 +134,7 @@ extension CatalogWorkflow { private func commitSnapshot( _ snapshot: SubscriptionSnapshot, summary: SubscriptionSummary, fallbackName: String ) async throws { + try Task.checkCancellation() var obsoleteCredentialRefs: Set = [] var credentialJournal = CredentialWriteJournal(credentials: dependencies.credentials) do { @@ -148,6 +151,7 @@ extension CatalogWorkflow { let resolved = try Self.resolveCredentials( snapshot.root, catalogGroupID: summary.groupID, reuse: reusedRefs, journal: &credentialJournal) + 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) @@ -156,13 +160,18 @@ extension CatalogWorkflow { obsoleteCredentialRefs = Set(Self.credentialRefs(of: replacedServers)) .subtracting(retainedRefs) if let index = subscriptions.firstIndex(where: { $0.id == summary.id }) { - subscriptions[index].status = .succeeded(at: Date()) + let succeededAt = Date() + subscriptions[index].status = .succeeded(at: succeededAt) + subscriptions[index].information = + snapshot.information.isEmpty ? nil : snapshot.information + subscriptions[index].lastSucceededAt = succeededAt } } } catch { // story 29:尽力恢复旧秘密;完整 outcome 只随 transient error 留在 // 内存,durable status 只会保存粗粒度 rollback 状态。 let rollback = credentialJournal.rollback() + if error is CancellationError { throw error } let category: SubscriptionRefreshFailure.CommitCategory = error is CredentialStoreError ? .credentials : .persistence throw SubscriptionRefreshCommitError(category: category, rollback: rollback) @@ -224,7 +233,9 @@ extension CatalogWorkflow { name: catalog.entry(for: record.groupID)?.displayName ?? "", host: host, status: record.status, - serverCount: serverCount) + serverCount: serverCount, + information: record.information, + lastSucceededAt: record.lastSucceededAt) } } diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift index 73a5c834..c83fd83d 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift @@ -300,6 +300,8 @@ struct SubscriptionSummary: Identifiable, Equatable { let host: String let status: SubscriptionRefreshStatus let serverCount: Int + var information: SubscriptionInformation? + var lastSucceededAt: Date? } /// Transient refresh result retained by the workflow. The durable status stores diff --git a/ShadowsocksX-NG2/App/Localizable.xcstrings b/ShadowsocksX-NG2/App/Localizable.xcstrings index 2923510d..bf556353 100644 --- a/ShadowsocksX-NG2/App/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Localizable.xcstrings @@ -275,6 +275,72 @@ } } } + }, + "已用流量": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used Data" + } + } + } + }, + "剩余流量": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remaining Data" + } + } + } + }, + "到期时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expiration" + } + } + } + }, + "重置时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Traffic Reset" + } + } + } + }, + "流量消耗": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Data Usage" + } + } + } + }, + "上次成功刷新": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last Successful Refresh" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/SubscriptionInformationPresentation.swift b/ShadowsocksX-NG2/App/SubscriptionInformationPresentation.swift new file mode 100644 index 00000000..2474e11e --- /dev/null +++ b/ShadowsocksX-NG2/App/SubscriptionInformationPresentation.swift @@ -0,0 +1,18 @@ +import Foundation + +extension SubscriptionInformation { + /// 只在两项都有效且总量非零时给出进度;先转浮点避免 UInt64 相加溢出。 + var usedFraction: Double? { + guard let bytesUsed, let bytesRemaining, bytesUsed != 0 || bytesRemaining != 0 else { + return nil + } + let used = Double(bytesUsed) + return used / (used + Double(bytesRemaining)) + } + + static func byteCountText(_ count: UInt64, locale: Locale) -> String { + // Measurement 的浮点输入覆盖完整 UInt64 范围,不经 Int64 截断。 + Measurement(value: Double(count), unit: UnitInformationStorage.bytes) + .formatted(.byteCount(style: .binary, spellsOutZero: false).locale(locale)) + } +} diff --git a/ShadowsocksX-NG2/App/SubscriptionsView.swift b/ShadowsocksX-NG2/App/SubscriptionsView.swift index 13f86837..6d503830 100644 --- a/ShadowsocksX-NG2/App/SubscriptionsView.swift +++ b/ShadowsocksX-NG2/App/SubscriptionsView.swift @@ -112,14 +112,6 @@ extension SubscriptionRefreshStatus { } } - var lastRefreshText: String { - switch self { - case .never: "尚未刷新" - case .succeeded(let date), .failed(let date, _): - date.formatted(date: .abbreviated, time: .shortened) - } - } - var failureDetail: String? { if case .failed(_, let failure) = self { return AppPresentation.message(for: failure) @@ -142,6 +134,8 @@ extension SubscriptionRefreshStatus { /// 单张订阅卡片(票 #56):头部、三列元数据与脱敏说明;失败态警告色描边。 private struct SubscriptionCard: View { + @Environment(\.locale) private var locale + @Environment(\.timeZone) private var timeZone let summary: SubscriptionSummary let groupName: String let isRefreshing: Bool @@ -156,6 +150,10 @@ private struct SubscriptionCard: View { .padding(.top, 16) meta .padding(.top, 14) + if let information = summary.information { + subscriptionInformation(information) + .padding(.top, 14) + } if let detail = summary.status.failureDetail { Text(detail) .font(.footnote) @@ -269,14 +267,59 @@ private struct SubscriptionCard: View { Text("\(summary.serverCount) 台") } metaColumn("上次成功刷新") { - Text(summary.status.lastRefreshText) + if let date = summary.lastSucceededAt { + dateText(date) + } else { + Text("—") + } } Spacer(minLength: 0) } } + @ViewBuilder + private func subscriptionInformation(_ information: SubscriptionInformation) -> some View { + VStack(alignment: .leading, spacing: 12) { + if information.bytesUsed != nil || information.bytesRemaining != nil { + HStack(alignment: .top, spacing: 24) { + if let used = information.bytesUsed { + metaColumn("已用流量") { + Text(SubscriptionInformation.byteCountText(used, locale: locale)) + } + } + if let remaining = information.bytesRemaining { + metaColumn("剩余流量") { + Text(SubscriptionInformation.byteCountText(remaining, locale: locale)) + } + } + } + if let fraction = information.usedFraction { + ProgressView(value: fraction) + .accessibilityLabel("流量消耗") + } + } + if information.expiresAt != nil || information.trafficResetAt != nil { + HStack(alignment: .top, spacing: 24) { + if let expiresAt = information.expiresAt { + metaColumn("到期时间") { dateText(expiresAt) } + } + if let resetAt = information.trafficResetAt { + metaColumn("重置时间") { dateText(resetAt) } + } + } + } + } + } + + private func dateText(_ date: Date) -> Text { + Text( + date, + format: Date.FormatStyle( + date: .abbreviated, time: .shortened, locale: locale, timeZone: timeZone)) + } + private func metaColumn( - _ label: String, @ViewBuilder content: () -> Content + _ label: LocalizedStringKey, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 3) { Text(label) diff --git a/ShadowsocksX-NG2/Domain/SubscriptionDocument.swift b/ShadowsocksX-NG2/Domain/SubscriptionDocument.swift index dfddcbf3..675dfc56 100644 --- a/ShadowsocksX-NG2/Domain/SubscriptionDocument.swift +++ b/ShadowsocksX-NG2/Domain/SubscriptionDocument.swift @@ -39,6 +39,7 @@ struct SubscriptionSnapshot: Equatable, Sendable { /// 根分组内容:落入订阅固定分组(固定分组身份客户端所有,不取自远端)。 let root: Group + var information = SubscriptionInformation() } /// 快照解析失败(整份拒绝,不做部分提交;spec #21 D4 刷新失败族)。 @@ -86,9 +87,12 @@ enum SubscriptionDocumentParser { return SubscriptionSnapshot( root: SubscriptionSnapshot.Group( id: NodeID(rawValue: "\(subscriptionID.rawValue):root"), - name: "", children: leaves.map { .server($0.leaf) })) + name: "", children: leaves.map { .server($0.leaf) }), + information: SubscriptionInformation.parse(data)) } - return buildSnapshot(tree: tree, leaves: leaves, subscriptionID: subscriptionID) + var snapshot = buildSnapshot(tree: tree, leaves: leaves, subscriptionID: subscriptionID) + snapshot.information = SubscriptionInformation.parse(data) + return snapshot } // MARK: - 服务器记录校验与身份 diff --git a/ShadowsocksX-NG2/Domain/SubscriptionInformation.swift b/ShadowsocksX-NG2/Domain/SubscriptionInformation.swift new file mode 100644 index 00000000..85384e94 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/SubscriptionInformation.swift @@ -0,0 +1,150 @@ +import Foundation + +/// 白名单订阅资料,不含服务器连接字段、凭据或原始响应。 +struct SubscriptionInformation: Codable, Equatable, Sendable { + var bytesUsed: UInt64? + var bytesRemaining: UInt64? + var expiresAt: Date? + var trafficResetAt: Date? + + var isEmpty: Bool { + bytesUsed == nil && bytesRemaining == nil && expiresAt == nil && trafficResetAt == nil + } + + /// 与服务器及分组扩展的解码分别处理;无效附加资料只影响该项。 + static func parse(_ data: Data) -> Self { + let root = rootFields(data) + return Self( + bytesUsed: byteCount(root["bytes_used"]), + bytesRemaining: byteCount(root["bytes_remaining"]), + expiresAt: date(root["expires_at"]), + trafficResetAt: date(root["traffic_reset_at"])) + } + + private static func byteCount(_ value: Data?) -> UInt64? { + guard let value, let raw = String(bytes: value, encoding: .utf8) else { return nil } + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, text.utf8.allSatisfy({ (48...57).contains($0) }) else { return nil } + return UInt64(text) + } + + private static func date(_ value: Data?) -> Date? { + guard let value, let text = try? JSONDecoder().decode(String.self, from: value) else { + return nil + } + let normalized = text.uppercased() + let pattern = + #"\A[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T"# + + #"([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?"# + + #"(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])\z"# + guard normalized.range(of: pattern, options: .regularExpression) != nil else { return nil } + // RFC 3339 的偏移可达 23:59,超出 Foundation 时区解析器的 18 小时范围。 + // 先解析原地时间,再应用数值偏移;不创建会失败的 TimeZone。 + let localText = normalized.hasSuffix("Z") ? normalized : String(normalized.dropLast(6)) + "Z" + guard + let local = try? Date.ISO8601FormatStyle( + includingFractionalSeconds: normalized.contains(".") + ).parse(localText) + else { return nil } + let parsed = local.addingTimeInterval(-Double(offsetSeconds(normalized))) + // Foundation 会把 2 月 30 日等无效日期向后归一化;按原偏移回读以拒绝这些值。 + let parts = normalized.prefix(19).split(whereSeparator: { "-T:".contains($0) }).compactMap { + Int($0) + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + let isLeapSecond = parts[5] == 60 + let checked = isLeapSecond ? local.addingTimeInterval(-1) : local + let fields: Set = [.year, .month, .day, .hour, .minute, .second] + let components = calendar.dateComponents(fields, from: checked) + guard components.year == parts[0], components.month == parts[1], + components.day == parts[2], components.hour == parts[3], components.minute == parts[4], + components.second == (isLeapSecond ? 59 : parts[5]) + else { return nil } + if isLeapSecond { + let utc = calendar.dateComponents(fields, from: parsed.addingTimeInterval(-1)) + guard utc.hour == 23, utc.minute == 59, + (utc.month == 6 && utc.day == 30) || (utc.month == 12 && utc.day == 31) + else { return nil } + } + return parsed + } + + private static func offsetSeconds(_ text: String) -> Int { + guard !text.hasSuffix("Z") else { return 0 } + let offset = text.suffix(6) + let hours = Int(offset.dropFirst().prefix(2))! + let minutes = Int(offset.suffix(2))! + return (hours * 60 + minutes) * 60 * (offset.first == "-" ? -1 : 1) + } + /// JSON 结构已由订阅文档解析器校验。这里只切出根级白名单值的原始片段, + /// 不把整个正文转换成 Any;某项超大数字因此不会损坏其他项的解码。 + private static func rootFields(_ data: Data) -> [String: Data] { + let bytes = Array(data) + let names: Set = ["bytes_used", "bytes_remaining", "expires_at", "traffic_reset_at"] + var result: [String: Data] = [:] + // 与现有 JSONDecoder 一致,接受可选的 UTF-8 BOM。 + var cursor = bytes.starts(with: [0xEF, 0xBB, 0xBF]) ? 3 : 0 + skipWhitespace(bytes, cursor: &cursor) + guard cursor < bytes.count, bytes[cursor] == 123 else { return result } + cursor += 1 + while cursor < bytes.count { + skipWhitespace(bytes, cursor: &cursor) + guard cursor < bytes.count, bytes[cursor] == 34 else { break } + let keyStart = cursor + skipString(bytes, cursor: &cursor) + let key = try? JSONDecoder().decode(String.self, from: Data(bytes[keyStart.. 0 else { return } + depth -= 1 + case 44: + if depth == 0 { return } + default: + break + } + cursor += 1 + } + } + + private static func skipWhitespace(_ bytes: [UInt8], cursor: inout Int) { + while cursor < bytes.count, [9, 10, 13, 32].contains(bytes[cursor]) { cursor += 1 } + } + + private static func skipString(_ bytes: [UInt8], cursor: inout Int) { + cursor += 1 + while cursor < bytes.count { + if bytes[cursor] == 92 { + cursor = min(cursor + 2, bytes.count) + } else if bytes[cursor] == 34 { + cursor += 1 + return + } else { + cursor += 1 + } + } + } + +} diff --git a/ShadowsocksX-NG2/Domain/SubscriptionRecord.swift b/ShadowsocksX-NG2/Domain/SubscriptionRecord.swift index b9391eed..0b89e37d 100644 --- a/ShadowsocksX-NG2/Domain/SubscriptionRecord.swift +++ b/ShadowsocksX-NG2/Domain/SubscriptionRecord.swift @@ -10,6 +10,10 @@ struct SubscriptionRecord: Codable, Equatable, Sendable, Identifiable { let groupID: NodeID var urlRef: CredentialReference var status: SubscriptionRefreshStatus + /// 最近成功提交的白名单资料;成功响应省略时清除,失败或取消保留。 + var information: SubscriptionInformation? + /// 独立于最近尝试状态,旧文件缺失时不猜测。 + var lastSucceededAt: Date? } /// 最近一次刷新结果。失败只保存白名单事实,不保存订阅 URL、秘密、凭据引用 diff --git a/ShadowsocksX-NG2/README.md b/ShadowsocksX-NG2/README.md index f117dd61..a43ca393 100644 --- a/ShadowsocksX-NG2/README.md +++ b/ShadowsocksX-NG2/README.md @@ -79,6 +79,14 @@ Scripts/packaging-gate.sh \ - 仓库级 `pre-commit` 钩子的启用方式和检查范围见根目录 [`README.md`](../README.md)。 - 调整规则只针对实际痛点:修改 `.swift-format` / `.swiftlint.yml` 的 PR 须给出触发案例,并同步更新本节中的基线版本。 +## 订阅资料 + +订阅可在 SIP-008 JSON 根级提供 `bytes_used`、`bytes_remaining`(非负整数,最大为 UInt64),以及自定义的 `expires_at`(服务到期时间)、`traffic_reset_at`(流量重置时间)。日期值使用 RFC 3339 字符串,省略或 `null` 表示未提供;无效资料仅忽略该项,不影响有效服务器或分组资料。 + +成功刷新完整替换白名单订阅资料,省略或无效的字段清除旧值;失败或取消保留最后成功资料与独立成功时间。资料和目录记录同文档原子保存,不另存原始响应或含密码的服务器明文快照;密码和插件参数沿用 Keychain 存储。旧目录文件缺少成功时间时不猜测。 + +订阅卡片显示返回的有效资料;仅在两项流量都有效且总量大于零时显示消耗进度。日期按用户当前语言与时区显示,到期和重置资料不改变代理启停或激活行为。 + ## 内置规则快照 规则模式使用的内置快照是显式维护产物: diff --git a/ShadowsocksX-NG2/Tests/SubscriptionInformationParserTests.swift b/ShadowsocksX-NG2/Tests/SubscriptionInformationParserTests.swift new file mode 100644 index 00000000..def57d84 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SubscriptionInformationParserTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +struct SubscriptionInformationParserTests { + @Test func subscriptionInformationIsParsedFromRootFields() throws { + let snapshot = try SubscriptionDocumentParser.parse( + Data( + #""" + {"version":1, + "servers":[], + "bytes_used":25, + "bytes_remaining":75, + "expires_at":"2026-10-04T08:30:00+08:00", + "traffic_reset_at":"2026-10-05T00:00:00.125Z"} + """# + .utf8), + subscriptionID: .fresh()) + + #expect(snapshot.information.bytesUsed == 25) + #expect(snapshot.information.bytesRemaining == 75) + #expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_073_800)) + #expect(snapshot.information.trafficResetAt == Date(timeIntervalSince1970: 1_791_158_400.125)) + } + @Test func rfc3339OffsetOutsideTimeZoneFactoryRangeIsAccepted() throws { + let snapshot = try SubscriptionDocumentParser.parse( + Data(#"{"version":1,"servers":[],"expires_at":"2026-10-04T23:59:00+23:59"}"#.utf8), + subscriptionID: .fresh()) + #expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_072_000)) + } + @Test(arguments: [ + "true", "false", "\"25\"", "-1", "1.5", "1.0", "1e3", "1e999", "18446744073709551616", "null", + "[]", + "{}", + ]) + func invalidByteCountsDoNotDiscardValidInformation(_ literal: String) throws { + let snapshot = try parse( + #""bytes_used":\#(literal),"bytes_remaining":75,"expires_at":"1970-01-01T00:00:00Z""#) + #expect(snapshot.information.bytesUsed == nil) + #expect(snapshot.information.bytesRemaining == 75) + #expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 0)) + } + + @Test func fullUnsignedRangeAndZeroAreAccepted() throws { + let snapshot = try parse(#""bytes_used":18446744073709551615,"bytes_remaining":0"#) + #expect(snapshot.information.bytesUsed == UInt64.max) + #expect(snapshot.information.bytesRemaining == 0) + } + + @Test(arguments: [ + #""2026-02-30T12:30:00Z""#, #""2026-10-04T24:00:00Z""#, + #""2026-10-04T00:00:00""#, #""2026-10-04T00:00:00Zjunk""#, + #""2026-10-04""#, #""2026-10-04T23:59:60Z""#, #""not a date""#, + "1791072000", "null", "true", "{}", "[]", + ]) + func invalidDateDoesNotAffectOtherFields(_ literal: String) throws { + let snapshot = try parse( + #""expires_at":\#(literal),"traffic_reset_at":"2026-10-04T00:00:00Z","bytes_used":0"#) + #expect(snapshot.information.expiresAt == nil) + #expect(snapshot.information.trafficResetAt == Date(timeIntervalSince1970: 1_791_072_000)) + #expect(snapshot.information.bytesUsed == 0) + } + + @Test func rfc3339CaseOffsetsAndLeapSecondPreserveInstants() throws { + let snapshot = try parse( + #""expires_at":"2026-10-03t19:00:00-05:00","traffic_reset_at":"2016-12-31T23:59:60Z""#) + #expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_072_000)) + #expect(snapshot.information.trafficResetAt == Date(timeIntervalSince1970: 1_483_228_800)) + } + + @Test func informationAndGroupingValidityAreIndependent() throws { + let treeText = try #require(String(bytes: SubscriptionDocs.tree(), encoding: .utf8)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let validTree = try SubscriptionDocumentParser.parse( + Data((treeText.dropLast() + #","bytes_used":25,"expires_at":123}"#).utf8), + subscriptionID: .fresh()) + #expect(validTree.root.name == "Example subscription") + #expect(validTree.information.bytesUsed == 25) + #expect(validTree.information.expiresAt == nil) + + let invalidTree = try parse(#""bytes_remaining":75,"x_shadowsocksx_ng":{"schema_version":99}"#) + #expect(invalidTree.root.name.isEmpty) + #expect(invalidTree.information.bytesRemaining == 75) + } + + @Test func onlyRootFieldsAreReadAcrossNestedEscapedAndUnrepresentableValues() throws { + let snapshot = try SubscriptionDocumentParser.parse( + Data( + #""" + {"version":1,"servers":[{"server":"203.0.113.7","server_port":8388, + "password":"{\"bytes_used\":999},\\tail","method":"aes-256-gcm","bytes_remaining":999}], + "ignored":1e999,"other":{"expires_at":"1970-01-01T00:00:00Z"}, + "bytes_\u0075sed":25,"bytes_remaining":75,"expires_at":"2026-10-04T00:00:00Z"} + """#.utf8), subscriptionID: .fresh()) + #expect(snapshot.information.bytesUsed == 25) + #expect(snapshot.information.bytesRemaining == 75) + #expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_072_000)) + #expect(snapshot.root.children.count == 1) + } + + @Test func utf8BOMPreservesValidSubscriptionInformation() throws { + let data = + Data([0xEF, 0xBB, 0xBF]) + + Data(#"{"version":1,"servers":[],"bytes_used":25,"bytes_remaining":75}"#.utf8) + let snapshot = try SubscriptionDocumentParser.parse(data, subscriptionID: .fresh()) + #expect(snapshot.information.bytesUsed == 25) + #expect(snapshot.information.bytesRemaining == 75) + } + + private func parse(_ fields: String) throws -> SubscriptionSnapshot { + try SubscriptionDocumentParser.parse( + Data("{\"version\":1,\"servers\":[],\(fields)}".utf8), subscriptionID: .fresh()) + } + +} diff --git a/ShadowsocksX-NG2/Tests/SubscriptionInformationPresentationTests.swift b/ShadowsocksX-NG2/Tests/SubscriptionInformationPresentationTests.swift new file mode 100644 index 00000000..29343eb5 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SubscriptionInformationPresentationTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +struct SubscriptionInformationPresentationTests { + @Test func usageRequiresBothCountsAndNonzeroTotal() { + #expect(SubscriptionInformation(bytesUsed: 25, bytesRemaining: 75).usedFraction == 0.25) + #expect(SubscriptionInformation(bytesUsed: 0, bytesRemaining: 0).usedFraction == nil) + #expect(SubscriptionInformation(bytesUsed: 25).usedFraction == nil) + #expect(SubscriptionInformation(bytesRemaining: 75).usedFraction == nil) + #expect(SubscriptionInformation(bytesUsed: 0, bytesRemaining: 75).usedFraction == 0) + #expect(SubscriptionInformation(bytesUsed: 25, bytesRemaining: 0).usedFraction == 1) + #expect( + SubscriptionInformation(bytesUsed: .max, bytesRemaining: .max).usedFraction == 0.5) + } + @Test func byteCountFormattingUsesLocaleAndDoesNotClampToSignedRange() { + let locale = Locale(identifier: "en_US") + #expect(SubscriptionInformation.byteCountText(0, locale: locale) == "0 bytes") + #expect(SubscriptionInformation.byteCountText(1024, locale: locale) == "1 kB") + #expect(SubscriptionInformation.byteCountText(.max, locale: locale) == "16,384 PB") + } + +} diff --git a/ShadowsocksX-NG2/Tests/SubscriptionInformationWorkflowTests.swift b/ShadowsocksX-NG2/Tests/SubscriptionInformationWorkflowTests.swift new file mode 100644 index 00000000..a2691ec2 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SubscriptionInformationWorkflowTests.swift @@ -0,0 +1,273 @@ +import Foundation +import Security +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct SubscriptionInformationWorkflowTests { + @Test func successfulInformationSurvivesFailureAndReload() async throws { + try await withWorkflow { workflow, store, credentials, fetcher in + let summary = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + #expect(summary.information?.bytesUsed == 25) + #expect(summary.information?.bytesRemaining == 75) + let succeededAt = try #require(summary.lastSucceededAt) + if case .succeeded(let attemptAt) = summary.status { + #expect(succeededAt == attemptAt) + } else { + Issue.record("应成功提交") + } + fetcher.setBehavior(.failure(.httpStatus(code: 503))) + await workflow.refreshSubscription(summary.id) + let failed = try #require(workflow.subscriptions.first) + #expect(failed.information == summary.information) + #expect(failed.lastSucceededAt == succeededAt) + let reloaded = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials) + #expect(reloaded.subscriptions.first?.information == summary.information) + #expect(reloaded.subscriptions.first?.lastSucceededAt == succeededAt) + } + } + + @Test func successfulRefreshReplacesMissingAndInvalidFields() async throws { + try await withWorkflow { workflow, _, _, fetcher in + let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + fetcher.setBehavior( + .success( + Data( + #"{"version":1,"servers":[],"bytes_used":50,"bytes_remaining":-1,"expires_at":123}"# + .utf8))) + await workflow.refreshSubscription(first.id) + let replaced = try #require(workflow.subscriptions.first) + #expect(replaced.information?.bytesUsed == 50) + #expect(replaced.information?.bytesRemaining == nil) + #expect(replaced.information?.expiresAt == nil) + + fetcher.setBehavior(.success(Data(#"{"version":1,"servers":[]}"#.utf8))) + await workflow.refreshSubscription(first.id) + let empty = try #require(workflow.subscriptions.first) + #expect(empty.information == nil) + if case .succeeded(let date) = empty.status { + #expect(empty.lastSucceededAt == date) + #expect(date >= first.lastSucceededAt!) + } else { + Issue.record("无资料响应仍应成功") + } + } + } + + @Test func cancelledRefreshCannotCommitEvenIfFetcherReturnsData() async throws { + try await withWorkflow { workflow, store, credentials, _ in + let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + let bytes = try Data(contentsOf: store.fileURL) + let fetcher = InformationGateFetcher() + let reloaded = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials, subscriptionFetcher: fetcher) + let refresh = Task { await reloaded.refreshSubscription(first.id) } + await fetcher.entered.wait() + refresh.cancel() + fetcher.release.release() + await refresh.value + #expect(reloaded.subscriptions.first == first) + #expect(try Data(contentsOf: store.fileURL) == bytes) + } + } + + @Test func persistenceFailureDoesNotPublishNewInformation() async throws { + try await withWorkflow { workflow, store, _, fetcher in + let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + let tree = workflow.tree + // 只破坏本夹具的文件路径,让原子替换失败,不影响其他 runner。 + let directory = URL(fileURLWithPath: store.fileURL.deletingLastPathComponent().path) + try FileManager.default.removeItem(at: directory) + try Data().write(to: directory) + fetcher.setBehavior(.success(Data(#"{"version":1,"servers":[],"bytes_used":100}"#.utf8))) + await workflow.refreshSubscription(first.id) + #expect(workflow.subscriptions.first?.information == first.information) + #expect(workflow.subscriptions.first?.lastSucceededAt == first.lastSucceededAt) + #expect(workflow.tree == tree) + } + } + + @Test func durableDocumentContainsOnlyAllowlistedInformationAndCredentialReferences() async throws + { + try await withWorkflow { workflow, store, credentials, fetcher in + fetcher.setBehavior( + .success( + Data( + #""" + {"version":1, + "servers":[{"server":"203.0.113.7", + "server_port":8388, + "method":"aes-256-gcm", + "password":"RAW-SERVER-PASSWORD", + "plugin":"v2ray-plugin", + "plugin_opts":"RAW-PLUGIN-OPTIONS"}], + "bytes_used":18446744073709551615, + "provider_token":"RAW-PROVIDER-TOKEN"} + """# + .utf8))) + let first = try await workflow.createSubscription( + urlString: "https://p.example.com/sub?secret=RAW-URL-TOKEN") + #expect(first.information?.bytesUsed == UInt64.max) + let raw = try String(contentsOf: store.fileURL, encoding: .utf8) + for secret in [ + "RAW-SERVER-PASSWORD", "RAW-PLUGIN-OPTIONS", "RAW-PROVIDER-TOKEN", "RAW-URL-TOKEN", + ] { + #expect(!raw.contains(secret)) + } + let reloaded = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials) + #expect(reloaded.subscriptions.first?.information?.bytesUsed == UInt64.max) + } + } + + @Test func oldDocumentWithoutInformationOrSuccessfulTimeLoadsWithoutGuessing() async throws { + try await withWorkflow { workflow, store, credentials, _ in + _ = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + var document = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: store.fileURL)) as? [String: Any]) + var records = try #require(document["subscriptions"] as? [[String: Any]]) + records[0].removeValue(forKey: "information") + records[0].removeValue(forKey: "lastSucceededAt") + document["subscriptions"] = records + try JSONSerialization.data(withJSONObject: document).write(to: store.fileURL) + let reloaded = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials) + #expect(reloaded.subscriptions.count == 1) + #expect(reloaded.subscriptions.first?.information == nil) + #expect(reloaded.subscriptions.first?.lastSucceededAt == nil) + } + } + + @Test func credentialFailureKeepsInformationAndSuccessfulTime() async throws { + try await withWorkflow { workflow, store, credentials, fetcher in + let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + let before = credentials.storageSnapshot + let rejecting = RejectingInformationCredentials(base: credentials) + let reloaded = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: rejecting, subscriptionFetcher: fetcher) + fetcher.setBehavior( + .success( + Data( + #""" + {"version":1, + "servers":[{"server":"203.0.113.7", + "server_port":8388, + "method":"aes-256-gcm", + "password":"REJECT-THIS-PASSWORD"}], + "bytes_used":100} + """# + .utf8))) + await reloaded.refreshSubscription(first.id) + let failed = try #require(reloaded.subscriptions.first) + #expect(failed.information == first.information) + #expect(failed.lastSucceededAt == first.lastSucceededAt) + if case .failed(_, let failure) = failed.status { + #expect(failure == .commit(category: .credentials, rollback: .restored)) + } else { + Issue.record("凭据失败应记失败态") + } + #expect(credentials.storageSnapshot == before) + #expect(try store.load().subscriptions.first?.information == first.information) + } + } + + @Test func cancellationDuringCredentialWritesRollsBackWithoutPublishing() async throws { + try await withWorkflow { workflow, store, credentials, fetcher in + let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub") + let before = credentials.storageSnapshot + let bytes = try Data(contentsOf: store.fileURL) + let reloaded = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: CancellingInformationCredentials(base: credentials), + subscriptionFetcher: fetcher) + fetcher.setBehavior( + .success( + Data( + #""" + {"version":1,"servers":[{"server":"203.0.113.7","server_port":8388, + "method":"aes-256-gcm","password":"CANCEL-DURING-SAVE"}],"bytes_used":100} + """#.utf8))) + let refresh = Task { await reloaded.refreshSubscription(first.id) } + await refresh.value + #expect(reloaded.subscriptions.first == first) + #expect(credentials.storageSnapshot == before) + #expect(try Data(contentsOf: store.fileURL) == bytes) + } + } + + private func withWorkflow( + _ body: + @MainActor ( + CatalogWorkflow, CatalogFileStore, InMemoryCredentialStore, FakeSubscriptionFetcher + ) async throws -> Void + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("subscription-information-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")) + let credentials = InMemoryCredentialStore() + let fetcher = FakeSubscriptionFetcher( + behavior: .success( + Data(#"{"version":1,"servers":[],"bytes_used":25,"bytes_remaining":75}"#.utf8))) + let workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials, subscriptionFetcher: fetcher) + try await body(workflow, store, credentials, fetcher) + } +} + +private struct InformationGateFetcher: SubscriptionFetching { + let entered = AsyncGate() + let release = AsyncGate() + + func fetch(_ url: URL) async throws -> Data { + entered.release() + await release.wait() + return Data(#"{"version":1,"servers":[],"bytes_used":100}"#.utf8) + } +} + +private struct RejectingInformationCredentials: CredentialStoring { + let base: InMemoryCredentialStore + + func save(_ secret: String, for reference: CredentialReference) throws { + guard secret != "REJECT-THIS-PASSWORD" else { + throw CredentialStoreError.keychainStatus(errSecAuthFailed) + } + try base.save(secret, for: reference) + } + + func secret(for reference: CredentialReference) throws -> String? { + try base.secret(for: reference) + } + + func delete(_ reference: CredentialReference) throws { + try base.delete(reference) + } +} + +private struct CancellingInformationCredentials: CredentialStoring { + let base: InMemoryCredentialStore + + func save(_ secret: String, for reference: CredentialReference) throws { + try base.save(secret, for: reference) + if secret == "CANCEL-DURING-SAVE" { + withUnsafeCurrentTask { $0?.cancel() } + } + } + + func secret(for reference: CredentialReference) throws -> String? { + try base.secret(for: reference) + } + + func delete(_ reference: CredentialReference) throws { + try base.delete(reference) + } +}