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
This commit is contained in:
Yaojia Wang
2026-07-04 21:53:41 +02:00
parent 2ab93c9682
commit 95438cdc12
32 changed files with 3772 additions and 4 deletions

View File

@@ -0,0 +1,23 @@
import Foundation
import WireProtocol
/// A paired web-terminal host (frozen contract, plan §3.3). Immutable
/// snapshot: identity (`id`) and dial info (`endpoint`) are fixed at creation;
/// "renaming" a host means upserting a new value with the same `id`.
///
/// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module;
/// downstream test/app targets that import Foundation should alias
/// `typealias Host = HostRegistry.Host` or qualify.
public struct Host: Sendable, Equatable, Codable, Identifiable {
public let id: UUID
public let name: String
/// Single point of Origin/wsURL derivation from WireProtocol, never
/// redeclared here (plan §6 rule 1).
public let endpoint: HostEndpoint
public init(id: UUID, name: String, endpoint: HostEndpoint) {
self.id = id
self.name = name
self.endpoint = endpoint
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
/// Frozen contract (plan §3.3). Implementations: `KeychainHostStore` (real)
/// and `InMemoryHostStore` (in-Sources double for this package's tests and
/// the App-layer VM tests). Immutable style: mutations return the NEW
/// collection instead of mutating shared state in place.
public protocol HostStore: Sendable {
func loadAll() async throws -> [Host]
/// Insert, or replace the host with the same `id` (position preserved).
/// Returns the new collection.
func upsert(_ host: Host) async throws -> [Host]
/// Removing an unknown `id` is an explicit no-op: returns the unchanged
/// collection, never throws for "not found".
func remove(id: UUID) async throws -> [Host]
}
// Pure collection transforms shared by all HostStore implementations (DRY);
// never mutate `self`, always return a fresh array.
extension [Host] {
func upserting(_ host: Host) -> [Host] {
contains(where: { $0.id == host.id })
? map { $0.id == host.id ? host : $0 }
: self + [host]
}
func removing(id: UUID) -> [Host] {
filter { $0.id != id }
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
/// In-memory `HostStore`. Lives in Sources (not Tests) on purpose plan
/// T-iOS-7: it doubles for the keychain store in this package's contract
/// tests AND in the App layer's ViewModel tests (which import HostRegistry).
///
/// Actor-isolated so concurrent upserts/removes serialize; the state is a
/// value-type snapshot replaced wholesale on every change (no in-place
/// mutation of shared references).
public actor InMemoryHostStore: HostStore {
private var hosts: [Host]
public init(hosts: [Host] = []) {
self.hosts = hosts
}
public func loadAll() async throws -> [Host] {
hosts
}
public func upsert(_ host: Host) async throws -> [Host] {
let updated = hosts.upserting(host)
hosts = updated
return updated
}
public func remove(id: UUID) async throws -> [Host] {
let updated = hosts.removing(id: id)
hosts = updated
return updated
}
}

View File

@@ -0,0 +1,114 @@
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
}
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
/// Frozen contract (plan §3.3). NON-SECRET per-host UI state only anything
/// sensitive belongs in `KeychainHostStore` (§5.3 split: Keychain = secrets,
/// UserDefaults = prefs).
public protocol LastSessionStore: Sendable {
func lastSessionId(host: UUID) -> UUID?
func setLastSessionId(_ id: UUID?, host: UUID)
}
/// UserDefaults-backed implementation with an injectable suite for tests.
/// `@unchecked Sendable`: `UserDefaults` carries no Sendable annotation in
/// the SDK but is documented thread-safe ("NSUserDefaults is thread-safe"),
/// and this wrapper adds no mutable state of its own.
public struct UserDefaultsLastSessionStore: LastSessionStore, @unchecked Sendable {
private static let keyPrefix = "lastSessionId."
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
public func lastSessionId(host: UUID) -> UUID? {
guard let raw = defaults.string(forKey: Self.key(host)) else { return nil }
// Stored value crosses a storage boundary validate, never trust:
// garbage reads back as nil instead of crashing or misrouting.
return UUID(uuidString: raw)
}
public func setLastSessionId(_ id: UUID?, host: UUID) {
guard let id else {
defaults.removeObject(forKey: Self.key(host))
return
}
defaults.set(id.uuidString, forKey: Self.key(host))
}
private static func key(_ host: UUID) -> String {
keyPrefix + host.uuidString
}
}

View File

@@ -0,0 +1,97 @@
import Foundation
import Security
/// Testability seam over the four `SecItem*` C calls (plan §3.3 / T-iOS-7).
///
/// Why a seam: unsigned `swift test` binaries get `errSecMissingEntitlement`
/// (-34018) from the data-protection keychain, so unit tests inject a fake
/// conforming to this protocol and assert the store's dictionaries verbatim;
/// the live shim only runs inside the signed simulator/app pass (T-iOS-15/16).
public protocol SecItemShim: Sendable {
func add(_ attributes: [String: any Sendable]) -> OSStatus
func update(query: [String: any Sendable],
attributesToUpdate: [String: any Sendable]) -> OSStatus
func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?)
func delete(_ query: [String: any Sendable]) -> OSStatus
}
/// Live passthrough to Security.framework. Deliberately logic-free: every
/// attribute including the §5.3-mandated protection keys is built by
/// `KeychainItemSpec` below, so there is exactly ONE place to audit and to
/// unit-test. Adding attribute logic here would split that single source.
public struct LiveSecItemShim: SecItemShim {
public init() {}
public func add(_ attributes: [String: any Sendable]) -> OSStatus {
SecItemAdd(attributes as [String: Any] as CFDictionary, nil)
}
public func update(query: [String: any Sendable],
attributesToUpdate: [String: any Sendable]) -> OSStatus {
SecItemUpdate(query as [String: Any] as CFDictionary,
attributesToUpdate as [String: Any] as CFDictionary)
}
public func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
var result: CFTypeRef?
let status = SecItemCopyMatching(query as [String: Any] as CFDictionary, &result)
return (status, result as? Data)
}
public func delete(_ query: [String: any Sendable]) -> OSStatus {
SecItemDelete(query as [String: Any] as CFDictionary)
}
}
/// §5.3 single source of truth for every keychain dictionary the store sends
/// through the shim. One wrong attribute here = credentials leave the device
/// with backups KeychainHostStoreTests asserts these dictionaries verbatim
/// (exact key sets), and the signed simulator pass (T-iOS-15/16) verifies the
/// real item's attributes end-to-end.
struct KeychainItemSpec: Sendable {
let service: String
let account: String
/// Identifies the item in every call. `kSecUseDataProtectionKeychain`
/// pins the iOS-style data-protection keychain on macOS too (on iOS it
/// is implicit); omitting it would silently fall back to the legacy
/// file keychain on Mac and void the §5.3 semantics.
func baseQuery() -> [String: any Sendable] {
[
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
}
func copyQuery() -> [String: any Sendable] {
merged(baseQuery(), [
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne as String,
])
}
/// `AfterFirstUnlockThisDeviceOnly`: readable in background after first
/// unlock (reconnect/UI needs), never migrates to another device or into
/// backups; no `kSecAttrSynchronizable` never iCloud-synced (§5.3).
func addAttributes(data: Data) -> [String: any Sendable] {
merged(baseQuery(), updateAttributes(data: data))
}
/// Update re-asserts `kSecAttrAccessible` so a pre-existing item can
/// never retain a weaker protection class than the current policy.
func updateAttributes(data: Data) -> [String: any Sendable] {
[
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
kSecValueData as String: data,
]
}
private func merged(
_ base: [String: any Sendable],
_ extra: [String: any Sendable]
) -> [String: any Sendable] {
base.merging(extra) { _, new in new }
}
}