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:
baiqing committed 2026-04-29 11:25:13 +08:00
1 parent 53cbdfbbff
commit c072816beb
6 files changed
+1043 -30

No files matched your search

+5
View File
@@ -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")
}
}