diff --git a/Package.swift b/Package.swift index b348d8ee..b88d8bee 100644 --- a/Package.swift +++ b/Package.swift @@ -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 类。 diff --git a/Sources/OpenLessPersistence/CredentialsError.swift b/Sources/OpenLessPersistence/CredentialsError.swift new file mode 100644 index 00000000..f12a2746 --- /dev/null +++ b/Sources/OpenLessPersistence/CredentialsError.swift @@ -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) +} diff --git a/Sources/OpenLessPersistence/CredentialsMigration.swift b/Sources/OpenLessPersistence/CredentialsMigration.swift new file mode 100644 index 00000000..21f15748 --- /dev/null +++ b/Sources/OpenLessPersistence/CredentialsMigration.swift @@ -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 + } +} diff --git a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift new file mode 100644 index 00000000..64cd2803 --- /dev/null +++ b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift @@ -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 +} diff --git a/Sources/OpenLessPersistence/CredentialsVault.swift b/Sources/OpenLessPersistence/CredentialsVault.swift index 37dbccd6..9f511bb8 100644 --- a/Sources/OpenLessPersistence/CredentialsVault.swift +++ b/Sources/OpenLessPersistence/CredentialsVault.swift @@ -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..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.[-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) - ) - } -} diff --git a/Tests/OpenLessPersistenceTests/CredentialsMigrationTests.swift b/Tests/OpenLessPersistenceTests/CredentialsMigrationTests.swift new file mode 100644 index 00000000..c637d26d --- /dev/null +++ b/Tests/OpenLessPersistenceTests/CredentialsMigrationTests.swift @@ -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") + } +}