feat(subscription): show usage and provider dates

This commit is contained in:
Qiu Yuzhou
2026-10-05 00:10:13 +08:00
parent 87701fbacd
commit 3b3528d232
13 changed files with 736 additions and 14 deletions
+3
View File
@@ -40,6 +40,9 @@ _避免用词_:导出(导出指配置组或诊断报告等文件快照)。
**订阅(Subscription)**:
具有持久身份的远程配置来源,对应一个固定的订阅组;订阅 URL 是可修改的访问地址,而非来源身份。
**订阅资料(Subscription information)**:
供应商随订阅提供的已用流量、剩余流量、服务到期时间与流量重置时间等事实。它不包含服务器连接资料、凭据或完整订阅响应。
**订阅组(Subscription group)**:
一个订阅拥有的配置子树,内容和结构以远程资料为准,用户不能在本地覆盖其成员、名称或连接字段。
@@ -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<CredentialReference> = []
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)
}
}
@@ -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
@@ -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"
@@ -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))
}
}
+53 -10
View File
@@ -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<Content: View>(
_ label: String, @ViewBuilder content: () -> Content
_ label: LocalizedStringKey, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
@@ -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: - 服务器记录校验与身份
@@ -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<Calendar.Component> = [.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<String> = ["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..<cursor]))
skipWhitespace(bytes, cursor: &cursor)
guard cursor < bytes.count, bytes[cursor] == 58 else { break }
cursor += 1
let valueStart = cursor
skipValue(bytes, cursor: &cursor)
if let key, names.contains(key) {
result[key] = Data(bytes[valueStart..<cursor])
}
guard cursor < bytes.count, bytes[cursor] == 44 else { break }
cursor += 1
}
return result
}
private static func skipValue(_ bytes: [UInt8], cursor: inout Int) {
var depth = 0
while cursor < bytes.count {
switch bytes[cursor] {
case 34:
skipString(bytes, cursor: &cursor)
continue
case 123, 91:
depth += 1
case 125, 93:
guard depth > 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
}
}
}
}
@@ -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、秘密、凭据引用
+8
View File
@@ -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 存储。旧目录文件缺少成功时间时不猜测。
订阅卡片显示返回的有效资料;仅在两项流量都有效且总量大于零时显示消耗进度。日期按用户当前语言与时区显示,到期和重置资料不改变代理启停或激活行为。
## 内置规则快照
规则模式使用的内置快照是显式维护产物:
@@ -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())
}
}
@@ -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")
}
}
@@ -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)
}
}