mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 22:23:43 +00:00
feat(subscription): show usage and provider dates
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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、秘密、凭据引用
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user