feat(persistence): migrate CredentialsVault to versioned v1 schema
Split flat v0 credentials.json into version=1 schema with provider sections (asr/llm), each keyed by providerId. Add automatic migration on first load with safe backup of the original v0 file. - New types: CredentialsSchemaV1, CredentialsProviderASRVolcengine, CredentialsProviderLLMArk, CredentialsActiveSelection - New CredentialsError enum: unparseable / futureVersion / ioError / backupFailed / writeFailed - Pure migration function (CredentialsMigration.parseAndMigrate) for unit testability — no disk I/O - Vault: atomic write via tmp + replaceItemAt, mode 0600 preserved - Backup format: credentials.v0.bak.<timestamp>[-N].json - Existing public API (get/set/remove/snapshot, CredentialAccount constants) is unchanged so SettingsHubTab / Sidebar continue to work - New OpenLessPersistenceTests target with 13 migration tests covering fresh install, full v0, partial v0 (asr-only / llm-only), empty ark.endpoint default, future-version safety, damaged-JSON safety, backup-name conflict suffix, idempotent save, atomic write, and 0600 permissions
This commit is contained in:
1 parent
53cbdfbbff
commit
c072816beb
6 files changed
+1043
-30
No files matched your search
@@ -90,6 +90,11 @@ let package = Package(
|
||||
dependencies: ["OpenLessApp"],
|
||||
path: "Tests/OpenLessAppTests"
|
||||
),
|
||||
.testTarget(
|
||||
name: "OpenLessPersistenceTests",
|
||||
dependencies: ["OpenLessPersistence", "OpenLessCore"],
|
||||
path: "Tests/OpenLessPersistenceTests"
|
||||
),
|
||||
],
|
||||
// 暂留 Swift 5 语言模式,避免一次性吞掉 Swift 6 严格并发的全部改造。
|
||||
// 升 .v6 时需要重新审视 Recorder / VolcengineStreamingASR 等 @unchecked Sendable 类。
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// 凭据存储相关错误。
|
||||
///
|
||||
/// `unparseable` / `futureVersion` 都意味着**不要碰原文件**,让用户自己处理或上报。
|
||||
public enum CredentialsError: Error, Sendable, Equatable {
|
||||
/// 文件存在但 JSON 解析失败。携带文件 URL,方便给用户显示具体路径。
|
||||
case unparseable(URL)
|
||||
|
||||
/// 文件版本号超过当前代码支持的范围。携带读到的版本号。
|
||||
case futureVersion(Int)
|
||||
|
||||
/// 通用 IO 错误(读 / 创建目录等)。携带可读描述。
|
||||
case ioError(String)
|
||||
|
||||
/// 备份 v0 文件失败。携带备份目标 URL。
|
||||
case backupFailed(URL)
|
||||
|
||||
/// 写入 v1 文件失败。携带目标 URL 和失败描述。
|
||||
case writeFailed(URL, String)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
/// 纯函数迁移:v0 扁平字典 → v1 schema。
|
||||
///
|
||||
/// 设计原则:
|
||||
/// - **纯解析 / 翻译**,不做任何磁盘 IO。便于单元测试。
|
||||
/// - 输入是已经从磁盘读出的原始 bytes / 解析过的字典;输出是判定结果(v1 model + 是否需要备份)。
|
||||
/// - 检测到 `version` 字段:v1 直接 decode;version > 1 抛 `futureVersion`。
|
||||
/// - 没有 `version` 字段:当作 v0,按 v0→v1 字段映射规则翻译。
|
||||
///
|
||||
/// 字段映射(v0 扁平字典 → v1):
|
||||
/// - `volcengine.app_key` → `providers.asr.volcengine.appKey`
|
||||
/// - `volcengine.access_key` → `providers.asr.volcengine.accessKey`
|
||||
/// - `volcengine.resource_id` → `providers.asr.volcengine.resourceId`
|
||||
/// - `ark.api_key` → `providers.llm.ark.apiKey`
|
||||
/// - `ark.endpoint` → `providers.llm.ark.baseURL` (空或缺失时回落 default)
|
||||
/// - `ark.model_id` → `providers.llm.ark.model`
|
||||
///
|
||||
/// 兼容备注:历史上 v0 既出现过点号风格(`volcengine.app_key`),也可能在外部文档里见到下划线风格
|
||||
/// (`volcengine_app_key`)。这里两种都接受,避免外部转储的文件迁移失败。
|
||||
public enum CredentialsMigration {
|
||||
/// 解析 / 迁移结果。
|
||||
public struct Result: Equatable, Sendable {
|
||||
public let schema: CredentialsSchemaV1
|
||||
/// 是否检测到 v0 数据并完成翻译,调用方需要执行"备份原文件 + 写新 v1 文件"。
|
||||
public let needsMigrationFromV0: Bool
|
||||
|
||||
public init(schema: CredentialsSchemaV1, needsMigrationFromV0: Bool) {
|
||||
self.schema = schema
|
||||
self.needsMigrationFromV0 = needsMigrationFromV0
|
||||
}
|
||||
}
|
||||
|
||||
/// 主入口:对原始 bytes 做版本嗅探并产出 v1 schema。
|
||||
///
|
||||
/// - Throws: `CredentialsError.unparseable(fileURL)` 当 JSON 不能解析;
|
||||
/// `CredentialsError.futureVersion(n)` 当 version > 1。
|
||||
public static func parseAndMigrate(rawData: Data, fileURL: URL) throws -> Result {
|
||||
let json: Any
|
||||
do {
|
||||
json = try JSONSerialization.jsonObject(with: rawData, options: [])
|
||||
} catch {
|
||||
throw CredentialsError.unparseable(fileURL)
|
||||
}
|
||||
|
||||
guard let dict = json as? [String: Any] else {
|
||||
throw CredentialsError.unparseable(fileURL)
|
||||
}
|
||||
|
||||
// 显式版本:直接走对应版本路径。
|
||||
if let version = dict["version"] as? Int {
|
||||
if version == 1 {
|
||||
let schema = try decodeV1(rawData: rawData, fileURL: fileURL)
|
||||
return Result(schema: schema, needsMigrationFromV0: false)
|
||||
}
|
||||
if version > 1 {
|
||||
throw CredentialsError.futureVersion(version)
|
||||
}
|
||||
// version == 0 或负数:当作 v0 处理(极少见,做兜底)。
|
||||
}
|
||||
|
||||
// 没有 version 字段(或 version <= 0):v0 扁平字典 → v1。
|
||||
let schema = translateV0Dict(dict)
|
||||
return Result(schema: schema, needsMigrationFromV0: true)
|
||||
}
|
||||
|
||||
// MARK: - v1 解码
|
||||
|
||||
private static func decodeV1(rawData: Data, fileURL: URL) throws -> CredentialsSchemaV1 {
|
||||
do {
|
||||
return try JSONDecoder().decode(CredentialsSchemaV1.self, from: rawData)
|
||||
} catch {
|
||||
throw CredentialsError.unparseable(fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - v0 → v1 字段翻译
|
||||
|
||||
/// v0 扁平字典翻译成 v1 schema。纯函数,调用方负责处理是否需要备份 / 写盘。
|
||||
public static func translateV0Dict(_ dict: [String: Any]) -> CredentialsSchemaV1 {
|
||||
let v0 = readV0Fields(dict)
|
||||
|
||||
var providers = CredentialsProviders()
|
||||
|
||||
// Volcengine:任一字段非空才落 provider 节。
|
||||
let volcAppKey = nilIfEmpty(v0.volcengineAppKey)
|
||||
let volcAccessKey = nilIfEmpty(v0.volcengineAccessKey)
|
||||
let volcResourceId = nilIfEmpty(v0.volcengineResourceId)
|
||||
if volcAppKey != nil || volcAccessKey != nil || volcResourceId != nil {
|
||||
providers.asr[defaultActiveASRProviderId] = CredentialsProviderASRVolcengine(
|
||||
appKey: volcAppKey,
|
||||
accessKey: volcAccessKey,
|
||||
resourceId: volcResourceId
|
||||
)
|
||||
}
|
||||
|
||||
// Ark:任一字段非空才落 provider 节。endpoint 空或缺失 → 落 default baseURL。
|
||||
let arkApiKey = nilIfEmpty(v0.arkApiKey)
|
||||
let arkModel = nilIfEmpty(v0.arkModelId)
|
||||
let arkEndpointRaw = v0.arkEndpoint
|
||||
let arkEndpointTrimmed = arkEndpointRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let arkBaseURL: String? = {
|
||||
if let v = arkEndpointTrimmed, !v.isEmpty { return v }
|
||||
return defaultArkBaseURL
|
||||
}()
|
||||
|
||||
if arkApiKey != nil || arkModel != nil || (arkEndpointTrimmed?.isEmpty == false) {
|
||||
providers.llm[defaultActiveLLMProviderId] = CredentialsProviderLLMArk(
|
||||
apiKey: arkApiKey,
|
||||
baseURL: arkBaseURL,
|
||||
model: arkModel
|
||||
)
|
||||
}
|
||||
|
||||
return CredentialsSchemaV1(
|
||||
version: 1,
|
||||
providers: providers,
|
||||
active: .defaults
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - v0 字段读取(容忍点号 / 下划线两种风格)
|
||||
|
||||
/// 读取 v0 扁平字典里的 6 个已知字段。点号 (`volcengine.app_key`) 是实际写盘格式,
|
||||
/// 同时接受下划线 (`volcengine_app_key`) 以兼容外部转储的文件。
|
||||
private static func readV0Fields(_ dict: [String: Any]) -> V0Fields {
|
||||
V0Fields(
|
||||
volcengineAppKey: stringValue(dict, keys: ["volcengine.app_key", "volcengine_app_key"]),
|
||||
volcengineAccessKey: stringValue(dict, keys: ["volcengine.access_key", "volcengine_access_key"]),
|
||||
volcengineResourceId: stringValue(dict, keys: ["volcengine.resource_id", "volcengine_resource_id"]),
|
||||
arkApiKey: stringValue(dict, keys: ["ark.api_key", "ark_api_key"]),
|
||||
arkModelId: stringValue(dict, keys: ["ark.model_id", "ark_model_id"]),
|
||||
arkEndpoint: stringValue(dict, keys: ["ark.endpoint", "ark_endpoint"])
|
||||
)
|
||||
}
|
||||
|
||||
private struct V0Fields {
|
||||
let volcengineAppKey: String?
|
||||
let volcengineAccessKey: String?
|
||||
let volcengineResourceId: String?
|
||||
let arkApiKey: String?
|
||||
let arkModelId: String?
|
||||
let arkEndpoint: String?
|
||||
}
|
||||
|
||||
private static func stringValue(_ dict: [String: Any], keys: [String]) -> String? {
|
||||
for k in keys {
|
||||
if let v = dict[k] as? String { return v }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func nilIfEmpty(_ s: String?) -> String? {
|
||||
guard let s else { return nil }
|
||||
return s.isEmpty ? nil : s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
|
||||
/// 凭据 v1 schema:版本化结构,按 provider 类别(asr / llm)→ providerId → 字段。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - `providers.asr` / `providers.llm` 用字典(键为 providerId),未来加 provider 不需要再涨版本号。
|
||||
/// - 每个 provider 子对象内部用 camelCase;所有字段都是 `String?`,缺省即"未设置"。
|
||||
/// - provider 子对象在没有任何字段时**省略不写**(不输出空对象),保持 JSON 干净。
|
||||
/// - `active` 必须存在并指向当前选中的 provider。M1 默认 `volcengine` / `ark`。
|
||||
public struct CredentialsSchemaV1: Codable, Sendable, Equatable {
|
||||
public var version: Int
|
||||
public var providers: CredentialsProviders
|
||||
public var active: CredentialsActiveSelection
|
||||
|
||||
public init(
|
||||
version: Int = 1,
|
||||
providers: CredentialsProviders = CredentialsProviders(),
|
||||
active: CredentialsActiveSelection = .defaults
|
||||
) {
|
||||
self.version = version
|
||||
self.providers = providers
|
||||
self.active = active
|
||||
}
|
||||
|
||||
/// 空 schema:无任何 provider 数据,active 用默认值。
|
||||
public static var empty: CredentialsSchemaV1 {
|
||||
CredentialsSchemaV1()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Providers
|
||||
|
||||
/// `providers` 节点。M1 类型化为已知的两类(asr / llm),未来扩展时把字典加到这里。
|
||||
public struct CredentialsProviders: Codable, Sendable, Equatable {
|
||||
/// ASR 类别:providerId → provider 配置。M1 仅有 `volcengine`。
|
||||
public var asr: [String: CredentialsProviderASRVolcengine]
|
||||
/// LLM 类别:providerId → provider 配置。M1 仅有 `ark`。
|
||||
public var llm: [String: CredentialsProviderLLMArk]
|
||||
|
||||
public init(
|
||||
asr: [String: CredentialsProviderASRVolcengine] = [:],
|
||||
llm: [String: CredentialsProviderLLMArk] = [:]
|
||||
) {
|
||||
self.asr = asr
|
||||
self.llm = llm
|
||||
}
|
||||
}
|
||||
|
||||
/// Volcengine streaming ASR 凭据。
|
||||
///
|
||||
/// 自定义编码:nil 字段直接省略(不写 `"appKey": null`),保持 JSON 干净。
|
||||
public struct CredentialsProviderASRVolcengine: Codable, Sendable, Equatable {
|
||||
public var appKey: String?
|
||||
public var accessKey: String?
|
||||
public var resourceId: String?
|
||||
|
||||
public init(appKey: String? = nil, accessKey: String? = nil, resourceId: String? = nil) {
|
||||
self.appKey = appKey
|
||||
self.accessKey = accessKey
|
||||
self.resourceId = resourceId
|
||||
}
|
||||
|
||||
/// 是否所有字段都是空 / nil。空 provider 在写盘时不应出现。
|
||||
public var isAllEmpty: Bool {
|
||||
isNilOrEmpty(appKey) && isNilOrEmpty(accessKey) && isNilOrEmpty(resourceId)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case appKey, accessKey, resourceId
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(appKey, forKey: .appKey)
|
||||
try c.encodeIfPresent(accessKey, forKey: .accessKey)
|
||||
try c.encodeIfPresent(resourceId, forKey: .resourceId)
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
appKey = try c.decodeIfPresent(String.self, forKey: .appKey)
|
||||
accessKey = try c.decodeIfPresent(String.self, forKey: .accessKey)
|
||||
resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ark (Doubao) chat-completions 凭据。
|
||||
///
|
||||
/// 自定义编码:nil 字段直接省略。
|
||||
public struct CredentialsProviderLLMArk: Codable, Sendable, Equatable {
|
||||
public var apiKey: String?
|
||||
public var baseURL: String?
|
||||
public var model: String?
|
||||
|
||||
public init(apiKey: String? = nil, baseURL: String? = nil, model: String? = nil) {
|
||||
self.apiKey = apiKey
|
||||
self.baseURL = baseURL
|
||||
self.model = model
|
||||
}
|
||||
|
||||
/// 是否所有字段都是空 / nil。
|
||||
public var isAllEmpty: Bool {
|
||||
isNilOrEmpty(apiKey) && isNilOrEmpty(baseURL) && isNilOrEmpty(model)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case apiKey, baseURL, model
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(apiKey, forKey: .apiKey)
|
||||
try c.encodeIfPresent(baseURL, forKey: .baseURL)
|
||||
try c.encodeIfPresent(model, forKey: .model)
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
apiKey = try c.decodeIfPresent(String.self, forKey: .apiKey)
|
||||
baseURL = try c.decodeIfPresent(String.self, forKey: .baseURL)
|
||||
model = try c.decodeIfPresent(String.self, forKey: .model)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active selection
|
||||
|
||||
/// 当前激活的 provider。M1 默认 `volcengine` / `ark`。
|
||||
public struct CredentialsActiveSelection: Codable, Sendable, Equatable {
|
||||
public var asr: String
|
||||
public var llm: String
|
||||
|
||||
public init(asr: String, llm: String) {
|
||||
self.asr = asr
|
||||
self.llm = llm
|
||||
}
|
||||
|
||||
public static var defaults: CredentialsActiveSelection {
|
||||
CredentialsActiveSelection(asr: defaultActiveASRProviderId, llm: defaultActiveLLMProviderId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 常量
|
||||
|
||||
public let defaultActiveASRProviderId = "volcengine"
|
||||
public let defaultActiveLLMProviderId = "ark"
|
||||
public let defaultArkBaseURL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
// MARK: - 工具
|
||||
|
||||
@inlinable
|
||||
func isNilOrEmpty(_ s: String?) -> Bool {
|
||||
guard let s else { return true }
|
||||
return s.isEmpty
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
/// 开发期凭据存储:明文 JSON,路径 `~/.openless/credentials.json`,权限 0600。
|
||||
/// 开发期凭据存储:JSON 文件,路径 `~/.openless/credentials.json`,权限 0600。
|
||||
///
|
||||
/// 内部存储为 v1 schema(`CredentialsSchemaV1`):版本化结构 + provider 分节。
|
||||
/// 旧的 v0 扁平字典文件首次加载时会被自动迁移:
|
||||
/// 1. 备份原文件到 `credentials.v0.bak.<timestamp>.json`
|
||||
/// 2. 翻译 v0 字段 → v1 schema
|
||||
/// 3. 原子写新 v1 文件(tmp + rename + 0600)
|
||||
///
|
||||
/// 公开 API(`get` / `set` / `remove` / `snapshot`)保留与 v0 一致的"扁平账号 key"语义,
|
||||
/// 内部把 `volcengine.app_key` 这样的 key 路由到 `providers.asr.volcengine.appKey` 字段。
|
||||
/// 这让 SettingsHubTab / Sidebar 等老调用点不用改字段访问就能继续工作。
|
||||
///
|
||||
/// 为什么不用 Keychain:
|
||||
/// 这个 .app 是 ad-hoc 签名(`codesign --sign -`),Keychain ACL 跟二进制 hash 强绑定。
|
||||
@@ -14,67 +24,335 @@ public final class CredentialsVault: @unchecked Sendable {
|
||||
public static let serviceName = "com.openless.app"
|
||||
public static let shared = CredentialsVault()
|
||||
|
||||
private let directoryURL: URL
|
||||
private let fileURL: URL
|
||||
private let lock = NSLock()
|
||||
private var cache: [String: String] = [:]
|
||||
private var schema: CredentialsSchemaV1 = .empty
|
||||
private var loaded = false
|
||||
/// 累积过的非致命加载错误(unparseable / futureVersion 等)。
|
||||
/// 调用方可读出来用于 UI 提示;这里**不抛出**,否则 vault 单例初始化路径会全线崩。
|
||||
private var lastLoadError: CredentialsError?
|
||||
|
||||
public init() {
|
||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||
public init(directoryURL: URL? = nil) {
|
||||
let dir = directoryURL ?? FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openless", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: dir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
self.directoryURL = dir
|
||||
self.fileURL = dir.appendingPathComponent("credentials.json")
|
||||
}
|
||||
|
||||
// MARK: - 公开 API(保持 v0 兼容签名)
|
||||
|
||||
/// 按账号 key 写入凭据。空字符串等价于删除。
|
||||
/// 写入后立即落盘(atomic rename + 0600)。
|
||||
public func set(_ value: String, for account: String) throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
if value.isEmpty {
|
||||
cache.removeValue(forKey: account)
|
||||
|
||||
let normalized = value
|
||||
if normalized.isEmpty {
|
||||
removeAccountLocked(account)
|
||||
} else {
|
||||
cache[account] = value
|
||||
setAccountLocked(account, value: normalized)
|
||||
}
|
||||
try writeLocked()
|
||||
}
|
||||
|
||||
/// 按账号 key 读取凭据。返回 nil 表示未设置 / 空。
|
||||
public func get(_ account: String) -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
return cache[account]
|
||||
return readAccountLocked(account)
|
||||
}
|
||||
|
||||
/// 按账号 key 删除凭据;写入失败被吞(这是历史 v0 行为)。
|
||||
public func remove(_ account: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
cache.removeValue(forKey: account)
|
||||
removeAccountLocked(account)
|
||||
try? writeLocked()
|
||||
}
|
||||
|
||||
/// 一次性把所有账号读出来;调用方在内存里持有,避免每次会话都打 IO。
|
||||
public func snapshot() -> CredentialsSnapshot {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
let volc = schema.providers.asr[defaultActiveASRProviderId]
|
||||
let ark = schema.providers.llm[defaultActiveLLMProviderId]
|
||||
return CredentialsSnapshot(
|
||||
volcengineAppKey: volc?.appKey,
|
||||
volcengineAccessKey: volc?.accessKey,
|
||||
volcengineResourceId: volc?.resourceId,
|
||||
arkApiKey: ark?.apiKey,
|
||||
arkModelId: ark?.model,
|
||||
arkEndpoint: ark?.baseURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 暴露当前内存中 v1 schema 的副本(值类型,安全)。
|
||||
public func currentSchema() -> CredentialsSchemaV1 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
return schema
|
||||
}
|
||||
|
||||
/// 最近一次 `loadIfNeeded` 期间累积的非致命错误(如果有)。
|
||||
public func loadError() -> CredentialsError? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lastLoadError
|
||||
}
|
||||
|
||||
// MARK: - 加载 / 迁移
|
||||
|
||||
private func loadIfNeededLocked() {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return }
|
||||
guard let dict = try? JSONDecoder().decode([String: String].self, from: data) else { return }
|
||||
cache = dict
|
||||
|
||||
// 文件不存在 → 空 v1 schema,不创建文件(首次 set 时再写)。
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
schema = .empty
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
// 读取失败:保留 empty schema,不删原文件。
|
||||
lastLoadError = .ioError("读取 credentials.json 失败: \(error.localizedDescription)")
|
||||
schema = .empty
|
||||
return
|
||||
}
|
||||
|
||||
let result: CredentialsMigration.Result
|
||||
do {
|
||||
result = try CredentialsMigration.parseAndMigrate(rawData: data, fileURL: fileURL)
|
||||
} catch let credErr as CredentialsError {
|
||||
// 解析失败 / 未来版本:原文件不动;schema 保持 empty。
|
||||
lastLoadError = credErr
|
||||
schema = .empty
|
||||
return
|
||||
} catch {
|
||||
lastLoadError = .ioError(String(describing: error))
|
||||
schema = .empty
|
||||
return
|
||||
}
|
||||
|
||||
schema = result.schema
|
||||
|
||||
// v0 → v1:先备份原文件,再写出新 v1 文件(atomic)。
|
||||
if result.needsMigrationFromV0 {
|
||||
do {
|
||||
try backupV0FileLocked()
|
||||
try writeLocked()
|
||||
} catch let credErr as CredentialsError {
|
||||
lastLoadError = credErr
|
||||
} catch {
|
||||
lastLoadError = .ioError(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把当前 fileURL 处的 v0 文件 move 到 `credentials.v0.bak.<timestamp>[-N].json`。
|
||||
/// 时间戳用紧凑 ISO8601(`yyyyMMddTHHmmss`)。冲突时追加 `-1`、`-2` 后缀。
|
||||
private func backupV0FileLocked() throws {
|
||||
let timestamp = compactISO8601Timestamp(date: Date())
|
||||
var target = directoryURL.appendingPathComponent("credentials.v0.bak.\(timestamp).json")
|
||||
var suffix = 1
|
||||
while FileManager.default.fileExists(atPath: target.path) {
|
||||
target = directoryURL.appendingPathComponent("credentials.v0.bak.\(timestamp)-\(suffix).json")
|
||||
suffix += 1
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.moveItem(at: fileURL, to: target)
|
||||
} catch {
|
||||
throw CredentialsError.backupFailed(target)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写入
|
||||
|
||||
/// 原子写:JSON → tmp → fsync → rename → 0600。
|
||||
private func writeLocked() throws {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o600],
|
||||
ofItemAtPath: fileURL.path
|
||||
// 写之前过滤掉空 provider 节,保持 JSON 干净。
|
||||
let cleaned = cleanedSchema(schema)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data: Data
|
||||
do {
|
||||
data = try encoder.encode(cleaned)
|
||||
} catch {
|
||||
throw CredentialsError.writeFailed(fileURL, "JSON 编码失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 确保目录存在(直接调用 vault 而没经过 init 的边角情况)。
|
||||
try? FileManager.default.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
|
||||
let tmpURL = directoryURL.appendingPathComponent("credentials.json.tmp")
|
||||
// 防止上次写崩留下的残骸。
|
||||
if FileManager.default.fileExists(atPath: tmpURL.path) {
|
||||
try? FileManager.default.removeItem(at: tmpURL)
|
||||
}
|
||||
|
||||
// 写 tmp + fsync。
|
||||
do {
|
||||
try data.write(to: tmpURL, options: [.atomic])
|
||||
} catch {
|
||||
throw CredentialsError.writeFailed(tmpURL, "写 tmp 文件失败: \(error.localizedDescription)")
|
||||
}
|
||||
// 显式 fsync 一下,防止 rename 后元数据丢失。
|
||||
if let fh = try? FileHandle(forWritingTo: tmpURL) {
|
||||
try? fh.synchronize()
|
||||
try? fh.close()
|
||||
}
|
||||
|
||||
// 原子替换:rename(tmp, target)。如果 target 已存在,replaceItemAt 也能正确处理。
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
_ = try FileManager.default.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
} catch {
|
||||
// 失败时清理 tmp,避免残骸误导下一次。
|
||||
try? FileManager.default.removeItem(at: tmpURL)
|
||||
throw CredentialsError.writeFailed(fileURL, "rename 失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 设置权限 0600。
|
||||
do {
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o600],
|
||||
ofItemAtPath: fileURL.path
|
||||
)
|
||||
} catch {
|
||||
throw CredentialsError.writeFailed(fileURL, "设置权限失败: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 兜底:万一 replaceItemAt 留下了 tmp(不同 macOS 行为不一致),统一清理。
|
||||
if FileManager.default.fileExists(atPath: tmpURL.path) {
|
||||
try? FileManager.default.removeItem(at: tmpURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// 写盘前过滤:移除 isAllEmpty 的 provider 节。
|
||||
private func cleanedSchema(_ s: CredentialsSchemaV1) -> CredentialsSchemaV1 {
|
||||
var out = s
|
||||
out.providers.asr = out.providers.asr.filter { !$0.value.isAllEmpty }
|
||||
out.providers.llm = out.providers.llm.filter { !$0.value.isAllEmpty }
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - 账号 key → v1 字段路由
|
||||
|
||||
private func readAccountLocked(_ account: String) -> String? {
|
||||
switch account {
|
||||
case CredentialAccount.volcengineAppKey:
|
||||
return schema.providers.asr[defaultActiveASRProviderId]?.appKey
|
||||
case CredentialAccount.volcengineAccessKey:
|
||||
return schema.providers.asr[defaultActiveASRProviderId]?.accessKey
|
||||
case CredentialAccount.volcengineResourceId:
|
||||
return schema.providers.asr[defaultActiveASRProviderId]?.resourceId
|
||||
case CredentialAccount.arkApiKey:
|
||||
return schema.providers.llm[defaultActiveLLMProviderId]?.apiKey
|
||||
case CredentialAccount.arkModelId:
|
||||
return schema.providers.llm[defaultActiveLLMProviderId]?.model
|
||||
case CredentialAccount.arkEndpoint:
|
||||
return schema.providers.llm[defaultActiveLLMProviderId]?.baseURL
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setAccountLocked(_ account: String, value: String) {
|
||||
switch account {
|
||||
case CredentialAccount.volcengineAppKey:
|
||||
mutateVolc { $0.appKey = value }
|
||||
case CredentialAccount.volcengineAccessKey:
|
||||
mutateVolc { $0.accessKey = value }
|
||||
case CredentialAccount.volcengineResourceId:
|
||||
mutateVolc { $0.resourceId = value }
|
||||
case CredentialAccount.arkApiKey:
|
||||
mutateArk { $0.apiKey = value }
|
||||
case CredentialAccount.arkModelId:
|
||||
mutateArk { $0.model = value }
|
||||
case CredentialAccount.arkEndpoint:
|
||||
mutateArk { $0.baseURL = value }
|
||||
default:
|
||||
// 未知 account:保持向后兼容,静默忽略。
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAccountLocked(_ account: String) {
|
||||
switch account {
|
||||
case CredentialAccount.volcengineAppKey:
|
||||
mutateVolc { $0.appKey = nil }
|
||||
case CredentialAccount.volcengineAccessKey:
|
||||
mutateVolc { $0.accessKey = nil }
|
||||
case CredentialAccount.volcengineResourceId:
|
||||
mutateVolc { $0.resourceId = nil }
|
||||
case CredentialAccount.arkApiKey:
|
||||
mutateArk { $0.apiKey = nil }
|
||||
case CredentialAccount.arkModelId:
|
||||
mutateArk { $0.model = nil }
|
||||
case CredentialAccount.arkEndpoint:
|
||||
mutateArk { $0.baseURL = nil }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func mutateVolc(_ apply: (inout CredentialsProviderASRVolcengine) -> Void) {
|
||||
var existing = schema.providers.asr[defaultActiveASRProviderId] ?? CredentialsProviderASRVolcengine()
|
||||
apply(&existing)
|
||||
if existing.isAllEmpty {
|
||||
schema.providers.asr.removeValue(forKey: defaultActiveASRProviderId)
|
||||
} else {
|
||||
schema.providers.asr[defaultActiveASRProviderId] = existing
|
||||
}
|
||||
}
|
||||
|
||||
private func mutateArk(_ apply: (inout CredentialsProviderLLMArk) -> Void) {
|
||||
var existing = schema.providers.llm[defaultActiveLLMProviderId] ?? CredentialsProviderLLMArk()
|
||||
apply(&existing)
|
||||
if existing.isAllEmpty {
|
||||
schema.providers.llm.removeValue(forKey: defaultActiveLLMProviderId)
|
||||
} else {
|
||||
schema.providers.llm[defaultActiveLLMProviderId] = existing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 时间戳工具
|
||||
|
||||
/// 紧凑 ISO8601 时间戳,例如 `20260429T103045`。固定 UTC,避免 DST。
|
||||
@inlinable
|
||||
func compactISO8601Timestamp(date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = "yyyyMMdd'T'HHmmss"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
// MARK: - Account constants
|
||||
|
||||
public enum CredentialAccount {
|
||||
public static let volcengineAppKey = "volcengine.app_key"
|
||||
public static let volcengineAccessKey = "volcengine.access_key"
|
||||
@@ -84,6 +362,8 @@ public enum CredentialAccount {
|
||||
public static let arkEndpoint = "ark.endpoint"
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
|
||||
/// 一次性把所有账号读出来;调用方在内存里持有,避免每次会话都打 IO。
|
||||
public struct CredentialsSnapshot: Sendable, Equatable {
|
||||
public let volcengineAppKey: String?
|
||||
@@ -109,16 +389,3 @@ public struct CredentialsSnapshot: Sendable, Equatable {
|
||||
self.arkEndpoint = arkEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
extension CredentialsVault {
|
||||
public func snapshot() -> CredentialsSnapshot {
|
||||
CredentialsSnapshot(
|
||||
volcengineAppKey: get(CredentialAccount.volcengineAppKey),
|
||||
volcengineAccessKey: get(CredentialAccount.volcengineAccessKey),
|
||||
volcengineResourceId: get(CredentialAccount.volcengineResourceId),
|
||||
arkApiKey: get(CredentialAccount.arkApiKey),
|
||||
arkModelId: get(CredentialAccount.arkModelId),
|
||||
arkEndpoint: get(CredentialAccount.arkEndpoint)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import XCTest
|
||||
@testable import OpenLessPersistence
|
||||
|
||||
/// 凭据 v0 → v1 迁移与原子写入的集成测试。
|
||||
///
|
||||
/// 用例策略:
|
||||
/// - 每个 case 独立 temp 目录,`addTeardownBlock` 清理。
|
||||
/// - 直接构造期望的 v0 文件(点号风格 key,与历史磁盘一致)让 vault 加载。
|
||||
/// - 通过 vault 的 public API(`get` / `set` / `snapshot`) 间接验证迁移结果与 v1 schema。
|
||||
final class CredentialsMigrationTests: XCTestCase {
|
||||
|
||||
// MARK: - 辅助
|
||||
|
||||
/// 给当前用例分配独立的 temp 目录,并在 teardown 时清理。
|
||||
private func makeTempDirectory() -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openless-credtest-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/// 直接写一个 v0 扁平字典文件到指定目录。
|
||||
private func writeV0File(_ dict: [String: String], to dir: URL) throws {
|
||||
let url = dir.appendingPathComponent("credentials.json")
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted])
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
/// 直接写一个 v1 schema 文件到指定目录。
|
||||
private func writeV1File(_ schema: CredentialsSchemaV1, to dir: URL) throws {
|
||||
let url = dir.appendingPathComponent("credentials.json")
|
||||
let data = try JSONEncoder().encode(schema)
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
/// 列目录里所有以 `credentials.v0.bak.` 开头的备份文件。
|
||||
private func backupFiles(in dir: URL) -> [URL] {
|
||||
let urls = (try? FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: []
|
||||
)) ?? []
|
||||
return urls.filter { $0.lastPathComponent.hasPrefix("credentials.v0.bak.") }
|
||||
}
|
||||
|
||||
/// 完整 v0 字典(6 个字段都填充)。
|
||||
private func fullV0Dict() -> [String: String] {
|
||||
[
|
||||
"volcengine.app_key": "vk-app",
|
||||
"volcengine.access_key": "vk-access",
|
||||
"volcengine.resource_id": "vk-resource",
|
||||
"ark.api_key": "ark-key",
|
||||
"ark.model_id": "ark-model",
|
||||
"ark.endpoint": "https://example.test/api/v3"
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - 用例
|
||||
|
||||
func test_freshInstall_noFile_loadsEmptyV1() {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
let snap = vault.snapshot()
|
||||
|
||||
// Assert
|
||||
XCTAssertNil(snap.volcengineAppKey)
|
||||
XCTAssertNil(snap.arkApiKey)
|
||||
|
||||
// 文件没存在(等到第一次 set 才写)。
|
||||
let fileURL = dir.appendingPathComponent("credentials.json")
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
|
||||
func test_v0_full_migratesToV1WithBackup() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File(fullV0Dict(), to: dir)
|
||||
let originalData = try Data(contentsOf: dir.appendingPathComponent("credentials.json"))
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
let snap = vault.snapshot()
|
||||
|
||||
// Assert: v1 字段读取一致
|
||||
XCTAssertEqual(snap.volcengineAppKey, "vk-app")
|
||||
XCTAssertEqual(snap.volcengineAccessKey, "vk-access")
|
||||
XCTAssertEqual(snap.volcengineResourceId, "vk-resource")
|
||||
XCTAssertEqual(snap.arkApiKey, "ark-key")
|
||||
XCTAssertEqual(snap.arkModelId, "ark-model")
|
||||
XCTAssertEqual(snap.arkEndpoint, "https://example.test/api/v3")
|
||||
|
||||
// 当前文件应该是 v1 格式(有 version 字段)
|
||||
let mainURL = dir.appendingPathComponent("credentials.json")
|
||||
let curData = try Data(contentsOf: mainURL)
|
||||
let curJson = try JSONSerialization.jsonObject(with: curData) as? [String: Any]
|
||||
XCTAssertEqual(curJson?["version"] as? Int, 1)
|
||||
|
||||
// 备份文件存在,且内容等于原 v0 bytes
|
||||
let backups = backupFiles(in: dir)
|
||||
XCTAssertEqual(backups.count, 1)
|
||||
let backupData = try Data(contentsOf: backups[0])
|
||||
XCTAssertEqual(backupData, originalData)
|
||||
}
|
||||
|
||||
func test_v0_partialOnlyArk_migrates() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File([
|
||||
"ark.api_key": "ark-key",
|
||||
"ark.model_id": "ark-model",
|
||||
"ark.endpoint": "https://example.test/api/v3"
|
||||
], to: dir)
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
|
||||
// Assert: 没有 volcengine 节
|
||||
let s = vault.currentSchema()
|
||||
XCTAssertNil(s.providers.asr[defaultActiveASRProviderId])
|
||||
XCTAssertNotNil(s.providers.llm[defaultActiveLLMProviderId])
|
||||
XCTAssertEqual(s.providers.llm[defaultActiveLLMProviderId]?.apiKey, "ark-key")
|
||||
|
||||
// 写盘的 v1 JSON 不应包含 asr.volcengine
|
||||
let main = try Data(contentsOf: dir.appendingPathComponent("credentials.json"))
|
||||
let json = try JSONSerialization.jsonObject(with: main) as? [String: Any]
|
||||
let providers = json?["providers"] as? [String: Any]
|
||||
let asr = providers?["asr"] as? [String: Any]
|
||||
XCTAssertTrue(asr?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func test_v0_partialOnlyVolcengine_migrates() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File([
|
||||
"volcengine.app_key": "vk-app",
|
||||
"volcengine.access_key": "vk-access",
|
||||
"volcengine.resource_id": "vk-resource"
|
||||
], to: dir)
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
|
||||
// Assert
|
||||
let s = vault.currentSchema()
|
||||
XCTAssertNotNil(s.providers.asr[defaultActiveASRProviderId])
|
||||
XCTAssertNil(s.providers.llm[defaultActiveLLMProviderId])
|
||||
XCTAssertEqual(s.providers.asr[defaultActiveASRProviderId]?.appKey, "vk-app")
|
||||
|
||||
let main = try Data(contentsOf: dir.appendingPathComponent("credentials.json"))
|
||||
let json = try JSONSerialization.jsonObject(with: main) as? [String: Any]
|
||||
let providers = json?["providers"] as? [String: Any]
|
||||
let llm = providers?["llm"] as? [String: Any]
|
||||
XCTAssertTrue(llm?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func test_v0_emptyArkEndpoint_usesDefault() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File([
|
||||
"ark.api_key": "ark-key",
|
||||
"ark.model_id": "ark-model",
|
||||
"ark.endpoint": "" // 空字符串
|
||||
], to: dir)
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
|
||||
// Assert: baseURL 落 default
|
||||
XCTAssertEqual(
|
||||
vault.snapshot().arkEndpoint,
|
||||
defaultArkBaseURL
|
||||
)
|
||||
}
|
||||
|
||||
func test_v0_emptyArkEndpoint_missing_usesDefault() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File([
|
||||
"ark.api_key": "ark-key",
|
||||
"ark.model_id": "ark-model"
|
||||
// ark.endpoint 完全不在 dict 里
|
||||
], to: dir)
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(vault.snapshot().arkEndpoint, defaultArkBaseURL)
|
||||
}
|
||||
|
||||
func test_alreadyV1_loadsWithoutMigration() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
let schema = CredentialsSchemaV1(
|
||||
version: 1,
|
||||
providers: CredentialsProviders(
|
||||
asr: [defaultActiveASRProviderId: CredentialsProviderASRVolcengine(appKey: "vk-app")],
|
||||
llm: [defaultActiveLLMProviderId: CredentialsProviderLLMArk(apiKey: "ark-key")]
|
||||
),
|
||||
active: .defaults
|
||||
)
|
||||
try writeV1File(schema, to: dir)
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
|
||||
// Assert: 字段读取一致
|
||||
XCTAssertEqual(vault.snapshot().volcengineAppKey, "vk-app")
|
||||
XCTAssertEqual(vault.snapshot().arkApiKey, "ark-key")
|
||||
|
||||
// 没有备份文件
|
||||
XCTAssertEqual(backupFiles(in: dir).count, 0)
|
||||
}
|
||||
|
||||
func test_futureVersion_throws() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
let url = dir.appendingPathComponent("credentials.json")
|
||||
let payload = ["version": 99] as [String: Any]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
try data.write(to: url)
|
||||
let originalBytes = try Data(contentsOf: url)
|
||||
|
||||
// Act:直接调用 pure migration 函数检查错误类型。
|
||||
XCTAssertThrowsError(try CredentialsMigration.parseAndMigrate(rawData: originalBytes, fileURL: url)) { err in
|
||||
guard case CredentialsError.futureVersion(let v) = err else {
|
||||
XCTFail("应该抛 futureVersion,实际: \(err)")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(v, 99)
|
||||
}
|
||||
|
||||
// Vault 路径下文件不应被修改(snapshot 触发 load)
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
_ = vault.snapshot()
|
||||
let after = try Data(contentsOf: url)
|
||||
XCTAssertEqual(after, originalBytes)
|
||||
// 也不应有备份
|
||||
XCTAssertEqual(backupFiles(in: dir).count, 0)
|
||||
// 错误应被记录到 loadError
|
||||
if case .futureVersion(let v) = vault.loadError() {
|
||||
XCTAssertEqual(v, 99)
|
||||
} else {
|
||||
XCTFail("应该把 futureVersion 错误存到 loadError")
|
||||
}
|
||||
}
|
||||
|
||||
func test_damagedJson_throws_fileUntouched() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
let url = dir.appendingPathComponent("credentials.json")
|
||||
let bad = "not-valid-json{".data(using: .utf8)!
|
||||
try bad.write(to: url)
|
||||
let originalBytes = try Data(contentsOf: url)
|
||||
|
||||
// Act
|
||||
XCTAssertThrowsError(try CredentialsMigration.parseAndMigrate(rawData: originalBytes, fileURL: url)) { err in
|
||||
guard case CredentialsError.unparseable(let u) = err else {
|
||||
XCTFail("应该抛 unparseable,实际: \(err)")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(u, url)
|
||||
}
|
||||
|
||||
// 通过 vault 加载也应保持文件不动
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
_ = vault.snapshot()
|
||||
let after = try Data(contentsOf: url)
|
||||
XCTAssertEqual(after, originalBytes)
|
||||
XCTAssertEqual(backupFiles(in: dir).count, 0)
|
||||
if case .unparseable(let u) = vault.loadError() {
|
||||
XCTAssertEqual(u, url)
|
||||
} else {
|
||||
XCTFail("应该把 unparseable 错误存到 loadError")
|
||||
}
|
||||
}
|
||||
|
||||
func test_backupConflict_appendsSuffix() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File(fullV0Dict(), to: dir)
|
||||
|
||||
// 预先放一个会冲突的备份占位文件(覆盖当前可能生成的所有时间戳)。
|
||||
// 最稳妥的办法:在迁移之前就把"所有"可能的时间戳备份名都占住。
|
||||
// 实际行为:迁移会生成 timestamp 形如 `yyyyMMddTHHmmss`。
|
||||
// 我们通过先迁移、抓到生成的备份名,再恢复 v0 文件、占住该名字、重新加载来制造冲突。
|
||||
|
||||
// 第一次迁移:先得到一个真实的备份文件名。
|
||||
do {
|
||||
let v = CredentialsVault(directoryURL: dir)
|
||||
_ = v.snapshot()
|
||||
}
|
||||
let firstBackups = backupFiles(in: dir)
|
||||
XCTAssertEqual(firstBackups.count, 1, "首次迁移应生成 1 个备份")
|
||||
let conflictName = firstBackups[0].lastPathComponent
|
||||
|
||||
// 重写 v0 文件 + 让冲突名字占住(不动它),然后清掉之前生成的 v1 main 文件并触发新一轮迁移。
|
||||
// 改成:不删除 conflictName,而是再次写入 v0 主文件,重新 init vault。
|
||||
// 由于 currentSchema 已经被 vault A 持久化为 v1,需要重置 main 为 v0 才能重新触发迁移。
|
||||
try FileManager.default.removeItem(at: dir.appendingPathComponent("credentials.json"))
|
||||
try writeV0File(fullV0Dict(), to: dir)
|
||||
|
||||
// Act:再次加载,时间戳同秒会撞 conflictName,应改用 `-1` 后缀。
|
||||
// 注意:测试可能跨秒,所以这里还是按"如果新备份文件名等于 conflictName,就要看 -1 文件"的逻辑判断。
|
||||
let vault2 = CredentialsVault(directoryURL: dir)
|
||||
XCTAssertNotNil(vault2.snapshot().volcengineAppKey)
|
||||
|
||||
let allBackups = backupFiles(in: dir)
|
||||
// 至少 2 个备份文件存在(旧的 + 新的)
|
||||
XCTAssertGreaterThanOrEqual(allBackups.count, 2)
|
||||
|
||||
// 如果第二次迁移的时间戳跟 conflictName 一样,应该出现 `-1` 后缀
|
||||
let suffixed = allBackups.first { $0.lastPathComponent.contains("-1.json") }
|
||||
let differentTimestamp = allBackups.first {
|
||||
$0.lastPathComponent != conflictName && !$0.lastPathComponent.contains("-1.json")
|
||||
}
|
||||
XCTAssertTrue(suffixed != nil || differentTimestamp != nil,
|
||||
"应该要么生成 -1 后缀文件,要么时间戳不同")
|
||||
|
||||
// conflict 文件本身不应该被覆盖
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: dir.appendingPathComponent(conflictName).path))
|
||||
}
|
||||
|
||||
func test_idempotentSave() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
let schema = CredentialsSchemaV1(
|
||||
version: 1,
|
||||
providers: CredentialsProviders(
|
||||
asr: [defaultActiveASRProviderId: CredentialsProviderASRVolcengine(
|
||||
appKey: "vk-app", accessKey: "vk-access", resourceId: "vk-resource"
|
||||
)],
|
||||
llm: [defaultActiveLLMProviderId: CredentialsProviderLLMArk(
|
||||
apiKey: "ark-key", baseURL: defaultArkBaseURL, model: "ark-model"
|
||||
)]
|
||||
),
|
||||
active: .defaults
|
||||
)
|
||||
try writeV1File(schema, to: dir)
|
||||
|
||||
// Act:load → save (随便 set 一次再设回原值) → load 再读
|
||||
let vault1 = CredentialsVault(directoryURL: dir)
|
||||
let s1 = vault1.currentSchema()
|
||||
try vault1.set("vk-app", for: CredentialAccount.volcengineAppKey)
|
||||
|
||||
let vault2 = CredentialsVault(directoryURL: dir)
|
||||
let s2 = vault2.currentSchema()
|
||||
|
||||
// Assert: schema 完全一致
|
||||
XCTAssertEqual(s1, s2)
|
||||
}
|
||||
|
||||
func test_atomicWrite_noTempLeftBehind() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
try vault.set("vk-app", for: CredentialAccount.volcengineAppKey)
|
||||
|
||||
// Assert
|
||||
let tmp = dir.appendingPathComponent("credentials.json.tmp")
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: tmp.path))
|
||||
}
|
||||
|
||||
func test_filePermissions_0600() throws {
|
||||
// Arrange
|
||||
let dir = makeTempDirectory()
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
try vault.set("vk-app", for: CredentialAccount.volcengineAppKey)
|
||||
|
||||
// Assert
|
||||
let main = dir.appendingPathComponent("credentials.json")
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: main.path)
|
||||
let perms = attrs[.posixPermissions] as? NSNumber
|
||||
XCTAssertEqual(perms?.intValue, 0o600)
|
||||
}
|
||||
|
||||
// MARK: - 兼容性补充:snake_case 风格 v0 也能识别(防御性)
|
||||
|
||||
func test_v0_snakeCaseKeys_alsoMigrates() throws {
|
||||
// Arrange:用文档里描述的下划线风格 key 写入。
|
||||
let dir = makeTempDirectory()
|
||||
try writeV0File([
|
||||
"volcengine_app_key": "vk-app",
|
||||
"volcengine_access_key": "vk-access",
|
||||
"volcengine_resource_id": "vk-resource",
|
||||
"ark_api_key": "ark-key",
|
||||
"ark_model_id": "ark-model",
|
||||
"ark_endpoint": "https://example.test/api/v3"
|
||||
], to: dir)
|
||||
|
||||
// Act
|
||||
let vault = CredentialsVault(directoryURL: dir)
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(vault.snapshot().volcengineAppKey, "vk-app")
|
||||
XCTAssertEqual(vault.snapshot().arkApiKey, "ark-key")
|
||||
XCTAssertEqual(vault.snapshot().arkEndpoint, "https://example.test/api/v3")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user