Files
web-terminal/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift
Yaojia Wang 95438cdc12 feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
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
2026-07-04 21:53:41 +02:00

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 (addupdate 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
}
}
}