Implement sealed storage and FIFO outbox to preserve edits across process kills
5 files changed,
+369
−56
Sources/RastrilloApple/NativeSealedStore.swift+103 −8Sources/RastrilloNative/Outbox.swift+152 −18Sources/RastrilloNative/SyncStatus.swift+25 −4Sources/RastrilloTestSupport/FaultingBlobStore.swift+69 −26docs/testing/k3-evidence.md+20 −0
CI failed — run details
Intent
diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift| index 3b75ec2..ff16d34 100644 |
| --- a/Sources/RastrilloApple/NativeSealedStore.swift |
| +++ b/Sources/RastrilloApple/NativeSealedStore.swift |
| @@ -53,16 +53,111 @@ public struct KeychainKeyProvider: KeyProvider { |
| public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) } |
| } |
| -/// STUB for the red run. |
| +/// Atomic authenticated files, bound to their final relative paths. A lock |
| +/// covers key creation and disk mutations; no plaintext is written to disk. |
| public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| public static let formatVersion = 1 |
| public let root: URL |
| public let accountID: String |
| - public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID } |
| - public func write(_ data: Data, to path: String) throws {} |
| - public func read(_ path: String) throws -> Data? { nil } |
| - public func list(_ directory: String) throws -> [String] { [] } |
| - public func remove(_ path: String) throws {} |
| - public func wipe() throws {} |
| - static let writeOptions: Data.WritingOptions = [] |
| + private let keys: KeyProvider |
| + private let lock = NSLock() |
| + private let fm = FileManager.default |
| + public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID; self.keys = keys } |
| + static let writeOptions: Data.WritingOptions = [.atomic, .completeFileProtection] |
| + private func url(_ path: String, allowRoot: Bool = false) throws -> URL { |
| + guard (allowRoot || !path.isEmpty), !path.hasPrefix("/"), !path.contains("\0"), |
| + !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." || $0 == "." }) else { throw BlobStoreError.unavailable } |
| + let result = root.appendingPathComponent(path) |
| + // Reject symlinks too: a relative spelling alone does not contain I/O. |
| + let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL.path |
| + let resolved = result.resolvingSymlinksInPath().standardizedFileURL.path |
| + guard resolved == resolvedRoot || resolved.hasPrefix(resolvedRoot + "/") else { throw BlobStoreError.unavailable } |
| + return result |
| + } |
| + private func missing(_ error: Error) -> Bool { |
| + let error = error as NSError |
| + return error.domain == NSCocoaErrorDomain && [NSFileReadNoSuchFileError, NSFileNoSuchFileError].contains(error.code) |
| + } |
| + private func io<T>(_ body: () throws -> T) throws -> T { |
| + do { return try body() } catch let error as BlobStoreError { throw error } catch { throw BlobStoreError.unavailable } |
| + } |
| + private func hasFiles(_ directory: URL) throws -> Bool { |
| + let children: [URL] |
| + do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) } |
| + catch { if missing(error) { return false }; throw error } |
| + for child in children { |
| + if try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true { |
| + if try hasFiles(child) { return true } |
| + } else { return true } |
| + } |
| + return false |
| + } |
| + private func key(create: Bool) throws -> SymmetricKey? { |
| + let (status, key) = keys.copyKey() |
| + if status == errSecSuccess, let key { return key } |
| + guard status == errSecItemNotFound else { throw BlobStoreError.unavailable } |
| + if try hasFiles(root) { |
| + try fm.removeItem(at: root) |
| + throw BlobStoreError.keyLost |
| + } |
| + guard create else { return nil } |
| + let new = SymmetricKey(size: .bits256) |
| + let added = keys.addKey(new) |
| + if added == errSecSuccess { return new } |
| + if added == errSecDuplicateItem { |
| + let (status, existing) = keys.copyKey() |
| + if status == errSecSuccess, let existing { return existing } |
| + } |
| + throw BlobStoreError.unavailable |
| + } |
| + private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) } |
| + public func write(_ data: Data, to path: String) throws { |
| + try lock.withLock { try io { |
| + let target = try url(path) |
| + guard let key = try key(create: true) else { throw BlobStoreError.unavailable } |
| + try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) |
| + var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true |
| + try directory.setResourceValues(values) |
| + let sealed = try AES.GCM.seal(data, using: key, authenticating: aad(path)) |
| + guard let combined = sealed.combined else { throw BlobStoreError.unavailable } |
| + try combined.write(to: target, options: Self.writeOptions) |
| + } } |
| + } |
| + public func read(_ path: String) throws -> Data? { |
| + try lock.withLock { try io { |
| + let target = try url(path) |
| + let data: Data |
| + do { data = try Data(contentsOf: target) } catch { if missing(error) { return nil }; throw error } |
| + guard let key = try key(create: false) else { throw BlobStoreError.unavailable } |
| + do { return try AES.GCM.open(AES.GCM.SealedBox(combined: data), using: key, authenticating: aad(path)) } |
| + catch { |
| + // Only an authentication/decode failure reaches this catch. I/O or |
| + // Keychain refusal must never turn into a destructive corruption path. |
| + try fm.removeItem(at: target) |
| + throw BlobStoreError.corrupt(path) |
| + } |
| + } } |
| + } |
| + public func list(_ directory: String) throws -> [String] { |
| + try lock.withLock { try io { |
| + let target = try url(directory, allowRoot: true) |
| + do { |
| + return try fm.contentsOfDirectory(at: target, includingPropertiesForKeys: [.isRegularFileKey]) |
| + .filter { try $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true }.map(\.lastPathComponent).sorted() |
| + } catch { if missing(error) { return [] }; throw error } |
| + } } |
| + } |
| + public func remove(_ path: String) throws { |
| + try lock.withLock { try io { |
| + let target = try url(path) |
| + do { try fm.removeItem(at: target) } catch { if !missing(error) { throw error } } |
| + } } |
| + } |
| + public func wipe() throws { |
| + try lock.withLock { try io { |
| + let status = keys.deleteKey() |
| + guard status == errSecSuccess || status == errSecItemNotFound else { throw BlobStoreError.unavailable } |
| + do { try fm.removeItem(at: root) } catch { if !missing(error) { throw error } } |
| + } } |
| + } |
| } |
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift| index 5c5afb4..da9b878 100644 |
| --- a/Sources/RastrilloNative/Outbox.swift |
| +++ b/Sources/RastrilloNative/Outbox.swift |
| @@ -90,30 +90,164 @@ public enum OutboxError: Error, Sendable, Equatable { |
| case duplicateEntry(UUID) |
| } |
| -/// STUB for the red run: every behaviour missing. |
| +/// Serializes disk mutations and send reservations for one account. Memory is |
| +/// changed after each successful disk operation, including partial batch acks. |
| public actor Outbox { |
| public nonisolated let account: String |
| public static let directory = "outbox" |
| - public init(store: BlobStore, account: String) { self.account = account } |
| - public func load() throws {} |
| + private let store: BlobStore |
| + private var values: [OutboxEntry] = [] |
| + private var flying: Set<UUID> = [] |
| + private var loaded = false |
| + private var unavailable = false |
| + private var lost = false |
| + private var next: UInt64 = 1 |
| + private var subscribers: [UUID: AsyncStream<OutboxSummary>.Continuation] = [:] |
| + public init(store: BlobStore, account: String) { self.store = store; self.account = account } |
| + private func path(_ seq: UInt64) -> String { "outbox/" + String(format: "%016llx", seq) + ".entry" } |
| + public func load() throws { |
| + guard !loaded || unavailable else { return } |
| + var recovered: [OutboxEntry] = [] |
| + do { |
| + let names = try store.list(Self.directory) |
| + for name in names where name.hasSuffix(".entry") { |
| + let file = Self.directory + "/" + name |
| + do { |
| + guard let data = try store.read(file) else { continue } |
| + guard let entry = try? JSONDecoder().decode(OutboxEntry.self, from: data), path(entry.seq) == file, |
| + !recovered.contains(where: { $0.id == entry.id }) else { |
| + try store.remove(file); lost = true; continue |
| + } |
| + recovered.append(entry) |
| + } catch BlobStoreError.corrupt { lost = true } |
| + } |
| + values = recovered.sorted { $0.seq < $1.seq } |
| + next = max(next, (values.last?.seq ?? 0) + 1) |
| + loaded = true; unavailable = false; notify() |
| + } catch BlobStoreError.keyLost { |
| + values = []; flying = []; loaded = true; unavailable = false; lost = true; notify() |
| + } catch { unavailable = true; notify(); throw error } |
| + } |
| + private func mutate<T>(_ body: () throws -> T) throws -> T { |
| + do { let result = try body(); unavailable = false; notify(); return result } |
| + catch { unavailable = true; notify(); throw error } |
| + } |
| + private func persist(_ entry: OutboxEntry) throws { try store.write(JSONEncoder().encode(entry), to: path(entry.seq)) } |
| @discardableResult |
| public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry { |
| - OutboxEntry(id: id, seq: 1, lane: lane, kind: kind, payload: payload, createdAt: Date()) |
| + try load() |
| + guard !values.contains(where: { $0.id == id }) else { throw OutboxError.duplicateEntry(id) } |
| + return try mutate { |
| + let entry = OutboxEntry(id: id, seq: next, lane: lane, kind: kind, payload: payload, createdAt: Date()) |
| + try persist(entry); values.append(entry); next += 1; return entry |
| + } |
| + } |
| + public func entries(lane: String) -> [OutboxEntry] { |
| + try? load() |
| + return values.filter { $0.lane == lane } |
| + } |
| + public func sendable(lane: String, maxBytes: Int? = nil) -> [OutboxEntry] { |
| + try? load() |
| + guard !unavailable else { return [] } |
| + var result: [OutboxEntry] = []; var bytes = 0 |
| + for entry in values where entry.lane == lane { |
| + if entry.hold != nil { break } |
| + // Compaction may use the suffix after a reserved batch, but beginSend |
| + // below never sends it before the earlier batch finishes. |
| + if flying.contains(entry.id) { continue } |
| + if let maxBytes, !result.isEmpty, bytes + entry.payload.count > maxBytes { break } |
| + result.append(entry); bytes += entry.payload.count |
| + } |
| + return result |
| + } |
| + public func beginSend(lane: String, maxBytes: Int? = nil) throws -> [OutboxEntry] { |
| + try load() |
| + guard flying.isEmpty else { return [] } |
| + let batch = sendable(lane: lane, maxBytes: maxBytes) |
| + flying.formUnion(batch.map(\.id)); notify(); return batch |
| + } |
| + public func endSend(_ ids: [UUID], failed: Bool = true) { |
| + for id in ids { |
| + flying.remove(id) |
| + if failed, let index = values.firstIndex(where: { $0.id == id }) { values[index].attempts += 1 } |
| + } |
| + notify() |
| } |
| - public func entries(lane: String) -> [OutboxEntry] { [] } |
| - public func sendable(lane: String, maxBytes: Int?) -> [OutboxEntry] { [] } |
| - public func beginSend(lane: String, maxBytes: Int?) throws -> [OutboxEntry] { [] } |
| - public func endSend(_ ids: [UUID], failed: Bool = true) {} |
| @discardableResult |
| public func replace(_ ids: [UUID], with payload: Data) throws -> OutboxEntry { |
| - OutboxEntry(id: UUID(), seq: 0, lane: "", kind: "", payload: payload, createdAt: Date()) |
| - } |
| - public func ack(_ ids: [UUID]) throws {} |
| - public func hold(_ ids: [UUID], _ hold: Hold) throws {} |
| - public func release(_ id: UUID) throws {} |
| - public func discard(lane: String) throws {} |
| - public func purge() throws {} |
| - public func acknowledgeLoss() {} |
| - public var summary: OutboxSummary { OutboxSummary() } |
| - public var changes: AsyncStream<OutboxSummary> { AsyncStream { $0.finish() } } |
| + try load() |
| + guard !ids.isEmpty, Set(ids).count == ids.count else { throw OutboxError.notContiguous } |
| + if ids.contains(where: { flying.contains($0) }) { throw OutboxError.inFlight } |
| + let run = try ids.map { id in |
| + guard let entry = values.first(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) } |
| + return entry |
| + }.sorted { $0.seq < $1.seq } |
| + let first = run[0] |
| + let lane = values.filter { $0.lane == first.lane } |
| + guard run.allSatisfy({ $0.lane == first.lane && $0.kind == first.kind }), |
| + let start = lane.firstIndex(where: { $0.id == first.id }), |
| + Array(lane.dropFirst(start).prefix(run.count)).map(\.id) == run.map(\.id) else { throw OutboxError.notContiguous } |
| + let sendableIDs = Set(sendable(lane: first.lane).map(\.id)) |
| + guard run.allSatisfy({ $0.hold == nil && sendableIDs.contains($0.id) }) else { throw OutboxError.held } |
| + return try mutate { |
| + let merged = OutboxEntry(id: UUID(), seq: first.seq, lane: first.lane, kind: first.kind, payload: payload, createdAt: first.createdAt) |
| + try persist(merged) |
| + values[values.firstIndex(where: { $0.id == first.id })!] = merged |
| + for entry in run.dropFirst() { |
| + try store.remove(path(entry.seq)); values.removeAll { $0.id == entry.id } |
| + } |
| + return merged |
| + } |
| + } |
| + public func ack(_ ids: [UUID]) throws { |
| + try load() |
| + try mutate { |
| + for id in ids { |
| + guard let entry = values.first(where: { $0.id == id }) else { continue } |
| + try store.remove(path(entry.seq)); values.removeAll { $0.id == id }; flying.remove(id) |
| + } |
| + } |
| + } |
| + public func hold(_ ids: [UUID], _ hold: Hold) throws { |
| + try load() |
| + try mutate { |
| + for id in ids { |
| + guard let index = values.firstIndex(where: { $0.id == id }) else { continue } |
| + var entry = values[index]; entry.hold = hold |
| + try persist(entry); values[index] = entry; flying.remove(id) |
| + } |
| + } |
| + } |
| + public func release(_ id: UUID) throws { |
| + try load() |
| + guard let index = values.firstIndex(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) } |
| + try mutate { var entry = values[index]; entry.hold = nil; try persist(entry); values[index] = entry } |
| + } |
| + public func discard(lane: String) throws { try load(); try ack(values.filter { $0.lane == lane }.map(\.id)) } |
| + public func purge() throws { |
| + try load() |
| + try mutate { try store.remove(Self.directory); values = []; flying = [] } |
| + } |
| + public func acknowledgeLoss() { lost = false; notify() } |
| + private var currentSummary: OutboxSummary { |
| + var result = OutboxSummary(); result.unavailable = unavailable; result.lostUnsent = lost |
| + for entry in values { |
| + var lane = result.lanes[entry.lane] ?? OutboxSummary.Lane() |
| + if let hold = entry.hold { lane.held += 1; if lane.firstHold == nil { lane.firstHold = hold.reason } } |
| + else { lane.pending += 1 } |
| + if flying.contains(entry.id) { lane.inFlight += 1 } |
| + lane.bytes += entry.payload.count; result.lanes[entry.lane] = lane |
| + } |
| + return result |
| + } |
| + public var summary: OutboxSummary { try? load(); return currentSummary } |
| + private func notify() { for subscriber in subscribers.values { subscriber.yield(currentSummary) } } |
| + private func unsubscribe(_ id: UUID) { subscribers[id] = nil } |
| + public var changes: AsyncStream<OutboxSummary> { |
| + try? load() |
| + let (stream, continuation) = AsyncStream<OutboxSummary>.makeStream(bufferingPolicy: .bufferingNewest(1)) |
| + let id = UUID(); subscribers[id] = continuation; continuation.yield(currentSummary) |
| + continuation.onTermination = { [weak self] _ in Task { await self?.unsubscribe(id) } } |
| + return stream |
| + } |
| } |
diff --git a/Sources/RastrilloNative/SyncStatus.swift b/Sources/RastrilloNative/SyncStatus.swift| index 5c20188..f97f943 100644 |
| --- a/Sources/RastrilloNative/SyncStatus.swift |
| +++ b/Sources/RastrilloNative/SyncStatus.swift |
| @@ -30,9 +30,30 @@ public enum SyncStatus: Sendable, Equatable { |
| case retrying(pending: Int) |
| public enum SaveState: String, Sendable { case saved, saving, retrying, failed } |
| - public var saveState: SaveState { .saved } |
| - public var isSticky: Bool { false } |
| - public var isAnnounced: Bool { false } |
| + public var saveState: SaveState { |
| + switch self { |
| + case .saved: return .saved |
| + case .saving: return .saving |
| + case .offline, .retrying: return .retrying |
| + case .failed, .needsSignIn: return .failed |
| + } |
| + } |
| + public var isSticky: Bool { saveState == .failed } |
| + public var isAnnounced: Bool { |
| + switch self { case .offline, .needsSignIn, .failed: return true; default: return false } |
| + } |
| public init(summary: OutboxSummary, sending: Bool, sessionEnded: Bool, offline: Bool, backingOff: Bool, |
| - localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { self = .saved } |
| + localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { |
| + let pending = count(summary) |
| + if let held = summary.lanes.sorted(by: { $0.key < $1.key }).compactMap({ $0.value.firstHold }).first { |
| + self = .failed(pending: pending, reason: .held(held)) |
| + } else if let localFailure { self = .failed(pending: pending, reason: localFailure) } |
| + else if summary.unavailable { self = .failed(pending: pending, reason: .cannotSaveOnDevice) } |
| + else if summary.isEmpty && summary.inFlight == 0 { self = .saved } |
| + else if sessionEnded { self = .needsSignIn(pending: pending) } |
| + else if sending || summary.inFlight > 0 { self = .saving } |
| + else if offline { self = .offline(pending: pending) } |
| + else if backingOff { self = .retrying(pending: pending) } |
| + else { self = .saving } |
| + } |
| } |
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift| index 14365fc..c45658c 100644 |
| --- a/Sources/RastrilloTestSupport/FaultingBlobStore.swift |
| +++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift |
| @@ -4,41 +4,84 @@ import RastrilloApple |
| import RastrilloNative |
| import Security |
| -/// STUB for the red run. |
| +/// Thread-safe in-memory disk. Copies model a new process reading committed bytes. |
| public final class MemoryBlobStore: BlobStore, @unchecked Sendable { |
| - public init(_ files: [String: Data] = [:]) {} |
| - public var contents: [String: Data] { [:] } |
| - public func write(_ data: Data, to path: String) throws {} |
| - public func read(_ path: String) throws -> Data? { nil } |
| - public func list(_ directory: String) throws -> [String] { [] } |
| - public func remove(_ path: String) throws {} |
| + private let lock = NSLock() |
| + private var files: [String: Data] |
| + public init(_ files: [String: Data] = [:]) { self.files = files } |
| + public var contents: [String: Data] { lock.withLock { files } } |
| + public func write(_ data: Data, to path: String) throws { lock.withLock { files[path] = data } } |
| + public func read(_ path: String) throws -> Data? { lock.withLock { files[path] } } |
| + public func list(_ directory: String) throws -> [String] { |
| + lock.withLock { |
| + let prefix = directory.isEmpty ? "" : directory + "/" |
| + return files.keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) } |
| + .filter { !$0.contains("/") }.sorted() |
| + } |
| + } |
| + public func remove(_ path: String) throws { |
| + lock.withLock { files = files.filter { !(path.isEmpty || $0.key == path || $0.key.hasPrefix(path + "/")) } } |
| + } |
| } |
| public struct SimulatedCrash: Error, Sendable, Equatable { |
| public let operation: Int |
| public init(operation: Int) { self.operation = operation } |
| } |
| +/// Crash *before* operation N (zero based). Once crashed, the committed view |
| +/// freezes and every later operation throws, just as a dead process cannot write. |
| public final class FaultingBlobStore: BlobStore, @unchecked Sendable { |
| public enum Operation: Sendable, Equatable { case write(String), read(String), list(String), remove(String) } |
| + private let lock = NSLock() |
| private let base: MemoryBlobStore |
| - public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base } |
| - public func crash(after operations: Int) {} |
| - public var unavailable: Bool { get { false } set {} } |
| - public var hasCrashed: Bool { false } |
| - public var operationCount: Int { 0 } |
| - public var operations: [Operation] { [] } |
| - public var committed: MemoryBlobStore { MemoryBlobStore() } |
| - public func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| - public func read(_ path: String) throws -> Data? { try base.read(path) } |
| - public func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| - public func remove(_ path: String) throws { try base.remove(path) } |
| + private var schedule: Int? |
| + private var frozen: [String: Data]? |
| + private var history: [Operation] = [] |
| + private var locked = false |
| + public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base; schedule = crashAt } |
| + public func crash(after operations: Int) { lock.withLock { schedule = history.count + operations } } |
| + public var unavailable: Bool { get { lock.withLock { locked } } set { lock.withLock { locked = newValue } } } |
| + public var hasCrashed: Bool { lock.withLock { frozen != nil } } |
| + public var operationCount: Int { lock.withLock { history.count } } |
| + public var operations: [Operation] { lock.withLock { history } } |
| + public var committed: MemoryBlobStore { lock.withLock { MemoryBlobStore(frozen ?? base.contents) } } |
| + private func perform<T>(_ operation: Operation, _ body: () throws -> T) throws -> T { |
| + try lock.withLock { |
| + if frozen != nil || schedule == history.count { |
| + if frozen == nil { frozen = base.contents } |
| + throw SimulatedCrash(operation: history.count) |
| + } |
| + if locked { throw BlobStoreError.unavailable } |
| + let value = try body() |
| + history.append(operation) |
| + return value |
| + } |
| + } |
| + public func write(_ data: Data, to path: String) throws { try perform(.write(path)) { try base.write(data, to: path) } } |
| + public func read(_ path: String) throws -> Data? { try perform(.read(path)) { try base.read(path) } } |
| + public func list(_ directory: String) throws -> [String] { try perform(.list(directory)) { try base.list(directory) } } |
| + public func remove(_ path: String) throws { try perform(.remove(path)) { try base.remove(path) } } |
| } |
| public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable { |
| - public init(key: SymmetricKey? = nil) {} |
| - public func failCopies(with status: OSStatus?) {} |
| - public func failAdds(with status: OSStatus?) {} |
| - public var storedKey: SymmetricKey? { nil } |
| - public var log: [String] { [] } |
| - public func copyKey() -> (OSStatus, SymmetricKey?) { (errSecItemNotFound, nil) } |
| - public func addKey(_ new: SymmetricKey) -> OSStatus { errSecSuccess } |
| - public func deleteKey() -> OSStatus { errSecSuccess } |
| + private let lock = NSLock() |
| + private var key: SymmetricKey? |
| + private var copyFailure: OSStatus? |
| + private var addFailure: OSStatus? |
| + private var history: [String] = [] |
| + public init(key: SymmetricKey? = nil) { self.key = key } |
| + public func failCopies(with status: OSStatus?) { lock.withLock { copyFailure = status } } |
| + public func failAdds(with status: OSStatus?) { lock.withLock { addFailure = status } } |
| + public var storedKey: SymmetricKey? { lock.withLock { key } } |
| + public var log: [String] { lock.withLock { history } } |
| + public func copyKey() -> (OSStatus, SymmetricKey?) { |
| + lock.withLock { history.append("copy"); return copyFailure.map { ($0, nil) } ?? (key == nil ? errSecItemNotFound : errSecSuccess, key) } |
| + } |
| + public func addKey(_ new: SymmetricKey) -> OSStatus { |
| + lock.withLock { |
| + history.append("add") |
| + if let addFailure { return addFailure } |
| + guard key == nil else { return errSecDuplicateItem } |
| + key = new; return errSecSuccess |
| + } |
| + } |
| + public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; return errSecSuccess } } |
| } |
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md| new file mode 100644 |
| index 0000000..163ebf0 |
| --- /dev/null |
| +++ b/docs/testing/k3-evidence.md |
| @@ -0,0 +1,20 @@ |
| +# K3 validation (in progress) |
| + |
| +Continuing WIP 68a51d7 against offline-kit spec 6049830 (discussion #5). |
| +The inherited implementations were explicitly no-op stubs, not working code. |
| + |
| +2026-09-24: ran the inherited tests against those stubs before implementation. |
| +`swift test` failed on missing enqueues, sends, replacement ordering, status |
| +mapping and store writes. One inherited outbox test indexed an empty result |
| +and terminated the process. A separate filtered run of `SyncStringsTests`, |
| +`SyncStatusTests`, `FaultingBlobStoreTests`, and `SealedStoreTests` executed 24 |
| +tests with 78 failures (including missing sealed files). Local transcripts: |
| +`/tmp/k3-kit-red.log`, `/tmp/k3-kit-red-rest.log`. |
| + |
| +After implementation, the storage/status/fault suite passed 32 tests and the |
| +drainer/catalog suite passed 18. `make ci` passed, including macOS and iOS |
| +companion builds, Go race tests, and Python bundle tests; transcript: |
| +`/tmp/k3-kit-ci-1.log`. This is intermediate evidence, not final K3 approval. |
| + |
| +Remaining: complete Docs adoption and crash/convergence evidence, seal-count |
| +rotation, final audit and full gates. Nothing has been merged or deployed. |