T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
115 lines
4.2 KiB
Swift
115 lines
4.2 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Explicit error taxonomy for keychain-backed persistence (plan T-iOS-7
|
|
/// "错误码显式映射"). `errSecDuplicateItem` and `errSecItemNotFound` are
|
|
/// BRANCHES (add→update fallback / empty list / idempotent delete), never
|
|
/// surfaced as errors.
|
|
public enum KeychainHostStoreError: Error, Equatable {
|
|
/// -34018 `errSecMissingEntitlement`: the data-protection keychain is
|
|
/// unavailable to unsigned binaries (e.g. `swift test`) — a signal to
|
|
/// inject a fake `SecItemShim`, not a user-facing failure.
|
|
case missingEntitlement
|
|
/// Stored payload is not a decodable `[Host]` — treated as an explicit
|
|
/// error, never a crash (defensive at the storage boundary).
|
|
case corruptedData
|
|
/// `[Host]` failed to JSON-encode (practically unreachable; kept for a
|
|
/// complete, explicit taxonomy on the write path).
|
|
case encodingFailed
|
|
case unexpectedStatus(OSStatus)
|
|
|
|
init(status: OSStatus) {
|
|
self = status == errSecMissingEntitlement ? .missingEntitlement : .unexpectedStatus(status)
|
|
}
|
|
}
|
|
|
|
/// Keychain-backed `HostStore`: the whole host list is ONE generic-password
|
|
/// item (JSON `[Host]`) under `service`/`account`. An actor so the
|
|
/// read-modify-write in `upsert`/`remove` serializes.
|
|
///
|
|
/// All SecItem dictionaries come from `KeychainItemSpec` — the §5.3 single
|
|
/// source; building an attribute dict anywhere else is a review CRITICAL.
|
|
public actor KeychainHostStore: HostStore {
|
|
public static let defaultService = "com.yaojia.webterm.host-registry"
|
|
public static let defaultAccount = "hosts"
|
|
|
|
private let shim: any SecItemShim
|
|
private let spec: KeychainItemSpec
|
|
|
|
public init(
|
|
shim: any SecItemShim = LiveSecItemShim(),
|
|
service: String = KeychainHostStore.defaultService,
|
|
account: String = KeychainHostStore.defaultAccount
|
|
) {
|
|
self.shim = shim
|
|
self.spec = KeychainItemSpec(service: service, account: account)
|
|
}
|
|
|
|
public func loadAll() async throws -> [Host] {
|
|
try readHosts()
|
|
}
|
|
|
|
public func upsert(_ host: Host) async throws -> [Host] {
|
|
let updated = try readHosts().upserting(host)
|
|
try persist(updated)
|
|
return updated
|
|
}
|
|
|
|
public func remove(id: UUID) async throws -> [Host] {
|
|
let current = try readHosts()
|
|
let updated = current.removing(id: id)
|
|
guard updated.count != current.count else {
|
|
return current // explicit no-op: unknown id, nothing persisted
|
|
}
|
|
try persist(updated)
|
|
return updated
|
|
}
|
|
|
|
// MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec)
|
|
|
|
private func readHosts() throws -> [Host] {
|
|
let (status, data) = shim.copyMatching(spec.copyQuery())
|
|
if status == errSecItemNotFound { return [] }
|
|
guard status == errSecSuccess else { throw KeychainHostStoreError(status: status) }
|
|
guard let data, let hosts = try? JSONDecoder().decode([Host].self, from: data) else {
|
|
throw KeychainHostStoreError.corruptedData
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
private func persist(_ hosts: [Host]) throws {
|
|
if hosts.isEmpty {
|
|
try deleteItem()
|
|
return
|
|
}
|
|
let data = try encodeHosts(hosts)
|
|
let addStatus = shim.add(spec.addAttributes(data: data))
|
|
if addStatus == errSecSuccess { return }
|
|
guard addStatus == errSecDuplicateItem else {
|
|
throw KeychainHostStoreError(status: addStatus)
|
|
}
|
|
let updateStatus = shim.update(
|
|
query: spec.baseQuery(),
|
|
attributesToUpdate: spec.updateAttributes(data: data)
|
|
)
|
|
guard updateStatus == errSecSuccess else {
|
|
throw KeychainHostStoreError(status: updateStatus)
|
|
}
|
|
}
|
|
|
|
private func deleteItem() throws {
|
|
let status = shim.delete(spec.baseQuery())
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw KeychainHostStoreError(status: status)
|
|
}
|
|
}
|
|
|
|
private func encodeHosts(_ hosts: [Host]) throws -> Data {
|
|
do {
|
|
return try JSONEncoder().encode(hosts)
|
|
} catch {
|
|
throw KeychainHostStoreError.encodingFailed
|
|
}
|
|
}
|
|
}
|