rastrillo / native Public

WIP: preserve rotation and independent-lane retry fixes at the disk stop

Paul Campbell pushed by paul@keymail.dev 6b6b19814c8df182954db36f1f4637bd0cda181a parent 05bf466
9 files changed, +192 −13
  • Package.swift +1 −1
  • Sources/RastrilloApple/NativeSealedStore.swift +92 −5
  • Sources/RastrilloApple/OutboxDrainer.swift +7 −3
  • Sources/RastrilloNative/Outbox.swift +2 −1
  • Sources/RastrilloTestSupport/FaultingBlobStore.swift +14 −3
  • Tests/RastrilloAppleTests/OutboxDrainerTests.swift +16 −0
  • Tests/RastrilloAppleTests/SealedStoreTests.swift +32 −0
  • Tests/RastrilloNativeTests/OutboxTests.swift +1 −0
  • docs/testing/k3-evidence.md +27 −0

CI failed — run details

diff --git a/Package.swift b/Package.swift
index ba69758..4be7b0e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -16,7 +16,7 @@ let package = Package(
.target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple", "RastrilloNative"]),
.target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]),
.testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]),
- .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative"]),
+ .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative", "RastrilloTestSupport"]),
.testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative", "RastrilloTestSupport"]),
.testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]),
]
diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift
index ff16d34..6d75f02 100644
--- a/Sources/RastrilloApple/NativeSealedStore.swift
+++ b/Sources/RastrilloApple/NativeSealedStore.swift
@@ -3,6 +3,17 @@ import Foundation
import RastrilloNative
import Security
+/// A single Keychain item makes key rotation crash recoverable: retain the old
+/// key until every file has been atomically resealed for its original path.
+public struct StoreKeyState: Codable, Sendable {
+ public var current: Data
+ public var previous: Data?
+ public var seals: UInt64
+ public init(current: Data, previous: Data? = nil, seals: UInt64 = 0) {
+ self.current = current; self.previous = previous; self.seals = seals
+ }
+}
+
/// Where an account's store key lives. `KeychainKeyProvider` is the real one;
/// tests script every Keychain status with `ScriptedKeyProvider`.
///
@@ -13,6 +24,8 @@ public protocol KeyProvider: Sendable {
func copyKey() -> (OSStatus, SymmetricKey?)
func addKey(_ key: SymmetricKey) -> OSStatus
func deleteKey() -> OSStatus
+ func sealState() -> (OSStatus, StoreKeyState?)
+ func saveSealState(_ state: StoreKeyState) -> OSStatus
}
/// One AES-256 key per account, a generic password under service
@@ -39,8 +52,10 @@ public struct KeychainKeyProvider: KeyProvider {
var result: CFTypeRef?
let status = SecItemCopyMatching(q as CFDictionary, &result)
guard status == errSecSuccess else { return (status, nil) }
- guard let data = result as? Data, data.count == 32 else { return (errSecDecode, nil) }
- return (status, SymmetricKey(data: data))
+ guard let data = result as? Data else { return (errSecDecode, nil) }
+ if data.count == 32 { return (status, SymmetricKey(data: data)) }
+ guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32 else { return (errSecDecode, nil) }
+ return (status, SymmetricKey(data: state.current))
}
public func addKey(_ key: SymmetricKey) -> OSStatus {
@@ -50,6 +65,21 @@ public struct KeychainKeyProvider: KeyProvider {
return SecItemAdd(q as CFDictionary, nil)
}
+ public func sealState() -> (OSStatus, StoreKeyState?) {
+ var q = query; q[kSecReturnData as String] = true; q[kSecMatchLimit as String] = kSecMatchLimitOne
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(q as CFDictionary, &result)
+ guard status == errSecSuccess, let data = result as? Data else { return (status, nil) }
+ if data.count == 32 { return (status, StoreKeyState(current: data)) }
+ guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32,
+ state.previous == nil || state.previous?.count == 32 else { return (errSecDecode, nil) }
+ return (status, state)
+ }
+ public func saveSealState(_ state: StoreKeyState) -> OSStatus {
+ guard let bytes = try? JSONEncoder().encode(state) else { return errSecParam }
+ return SecItemUpdate(query as CFDictionary, [kSecValueData as String: bytes] as CFDictionary)
+ }
+
public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) }
}
@@ -62,6 +92,7 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable {
private let keys: KeyProvider
private let lock = NSLock()
private let fm = FileManager.default
+ var rotationLimit: UInt64 = 1 << 31
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 {
@@ -111,10 +142,65 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable {
throw BlobStoreError.unavailable
}
private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) }
+ private func readKeyState() throws -> StoreKeyState {
+ let (status, state) = keys.sealState()
+ guard status == errSecSuccess, let state else { throw BlobStoreError.unavailable }
+ return state
+ }
+ private func saveKeyState(_ state: StoreKeyState) throws {
+ guard keys.saveSealState(state) == errSecSuccess else { throw BlobStoreError.unavailable }
+ }
+ private func reserveSeal(_ state: inout StoreKeyState) throws {
+ guard state.seals < rotationLimit else { throw BlobStoreError.unavailable }
+ state.seals += 1
+ // Count before sealing: a kill can overcount, but can never reuse a quota.
+ try saveKeyState(state)
+ }
+ private func open(_ data: Data, path: String, state: StoreKeyState) throws -> Data {
+ let box = try AES.GCM.SealedBox(combined: data)
+ do { return try AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path)) }
+ catch {
+ guard let previous = state.previous else { throw error }
+ return try AES.GCM.open(box, using: SymmetricKey(data: previous), authenticating: aad(path))
+ }
+ }
+ private func files(_ directory: URL) throws -> [URL] {
+ let children: [URL]
+ do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) }
+ catch { if missing(error) { return [] }; throw error }
+ return try children.flatMap { child in
+ try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true ? files(child) : [child]
+ }
+ }
+ private func rotateIfNeeded(_ state: inout StoreKeyState) throws {
+ guard state.previous != nil || state.seals >= rotationLimit else { return }
+ if state.previous == nil {
+ state = StoreKeyState(current: SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }, previous: state.current)
+ try saveKeyState(state)
+ }
+ for file in try files(root) {
+ let path = String(file.resolvingSymlinksInPath().path.dropFirst(root.resolvingSymlinksInPath().path.count + 1))
+ let combined = try Data(contentsOf: file)
+ if let box = try? AES.GCM.SealedBox(combined: combined),
+ (try? AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path))) != nil { continue }
+ let plaintext: Data
+ do { plaintext = try open(combined, path: path, state: state) }
+ catch { try fm.removeItem(at: file); throw BlobStoreError.corrupt(path) }
+ try reserveSeal(&state)
+ let resealed = try AES.GCM.seal(plaintext, using: SymmetricKey(data: state.current), authenticating: aad(path))
+ try resealed.combined!.write(to: file, options: Self.writeOptions)
+ }
+ state.previous = nil
+ try saveKeyState(state)
+ }
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 key(create: true)
+ var state = try readKeyState()
+ try rotateIfNeeded(&state)
+ try reserveSeal(&state)
+ let key = SymmetricKey(data: state.current)
try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true
try directory.setResourceValues(values)
@@ -128,8 +214,9 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable {
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)) }
+ guard try key(create: false) != nil else { throw BlobStoreError.unavailable }
+ let state = try readKeyState()
+ do { return try open(data, path: path, state: state) }
catch {
// Only an authentication/decode failure reaches this catch. I/O or
// Keychain refusal must never turn into a destructive corruption path.
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift
index 11c08ce..604392f 100644
--- a/Sources/RastrilloApple/OutboxDrainer.swift
+++ b/Sources/RastrilloApple/OutboxDrainer.swift
@@ -143,16 +143,20 @@ import RastrilloNative
if !connectivity.isPathSatisfied { phase = .waitingForNetwork; return .waitingForNetwork }
if let deadline = serverDeadline, deadline > now { phase = .backingOff; return .rateLimited(deadline - now) }
serverDeadline = nil
+ var retryLanes: Set<String> = []
while !Task.isCancelled {
summary = await outbox.summary
var batch: [OutboxEntry] = []
do {
- for lane in summary.lanes.keys.sorted() {
+ for lane in summary.lanes.keys.sorted() where !retryLanes.contains(lane) {
batch = try await outbox.beginSend(lane: lane, maxBytes: maxBatchBytes)
if !batch.isEmpty { break }
}
} catch { phase = .storeUnavailable; return .storeUnavailable }
- guard let first = batch.first else { phase = .idle; return .idle }
+ guard let first = batch.first else {
+ if !retryLanes.isEmpty { return retry() }
+ phase = .idle; return .idle
+ }
let ids = batch.map(\.id)
guard let transport = transport() else {
await outbox.endSend(ids, failed: false); sessionEnded = true; phase = .sessionEnded; return .sessionEnded
@@ -184,7 +188,7 @@ import RastrilloNative
}
await outbox.endSend(ids, failed: false)
summary = await outbox.summary
- if mustRetry || !progressed { return retry() }
+ if mustRetry || !progressed { retryLanes.insert(first.lane); continue }
backoff.reset(); retryDeadline = nil
} catch {
await outbox.endSend(ids); summary = await outbox.summary
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift
index da9b878..0ddc905 100644
--- a/Sources/RastrilloNative/Outbox.swift
+++ b/Sources/RastrilloNative/Outbox.swift
@@ -164,7 +164,8 @@ public actor Outbox {
try load()
guard flying.isEmpty else { return [] }
let batch = sendable(lane: lane, maxBytes: maxBytes)
- flying.formUnion(batch.map(\.id)); notify(); return batch
+ if !batch.isEmpty { flying.formUnion(batch.map(\.id)); notify() }
+ return batch
}
public func endSend(_ ids: [UUID], failed: Bool = true) {
for id in ids {
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift
index c45658c..ecf5c77 100644
--- a/Sources/RastrilloTestSupport/FaultingBlobStore.swift
+++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift
@@ -64,10 +64,11 @@ public final class FaultingBlobStore: BlobStore, @unchecked Sendable {
public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable {
private let lock = NSLock()
private var key: SymmetricKey?
+ private var state: StoreKeyState?
private var copyFailure: OSStatus?
private var addFailure: OSStatus?
private var history: [String] = []
- public init(key: SymmetricKey? = nil) { self.key = key }
+ public init(key: SymmetricKey? = nil) { self.key = key; state = key.map { StoreKeyState(current: $0.withUnsafeBytes { Data($0) }) } }
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 } }
@@ -80,8 +81,18 @@ public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable {
history.append("add")
if let addFailure { return addFailure }
guard key == nil else { return errSecDuplicateItem }
- key = new; return errSecSuccess
+ key = new; state = StoreKeyState(current: new.withUnsafeBytes { Data($0) }); return errSecSuccess
}
}
- public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; return errSecSuccess } }
+ public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; state = nil; return errSecSuccess } }
+ public func sealState() -> (OSStatus, StoreKeyState?) {
+ lock.withLock { copyFailure.map { ($0, nil) } ?? (state == nil ? errSecItemNotFound : errSecSuccess, state) }
+ }
+ public func saveSealState(_ value: StoreKeyState) -> OSStatus {
+ lock.withLock {
+ if let addFailure { return addFailure }
+ state = value; key = SymmetricKey(data: value.current); return errSecSuccess
+ }
+ }
+
}
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift
index a7baacb..aa3df64 100644
--- a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift
+++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift
@@ -269,4 +269,20 @@ import XCTest
await settle { drainer.summary.isEmpty }
XCTAssertEqual(drainer.status(), .saved)
}
+ func testAnAppRetryDoesNotStarveOtherLanes() async throws {
+ let (connectivity, _) = await connectivity()
+ let outbox = try await outbox([("doc:a-draft", "1"), ("doc:z-ready", "2")])
+ let transport = ScriptedTransport([])
+ var sent: [String] = []
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in
+ sent.append(lane)
+ return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, lane == "doc:a-draft" ? .retry : .sent) })
+ }
+ let result = await drainer.drain()
+ guard case .backingOff = result else { return XCTFail("The draft still needs a retry") }
+ XCTAssertEqual(sent, ["doc:a-draft", "doc:z-ready"])
+ let remaining = await outbox.entries(lane: "doc:z-ready")
+ XCTAssertTrue(remaining.isEmpty, "An unattached draft must not block another document")
+ }
+
}
diff --git a/Tests/RastrilloAppleTests/SealedStoreTests.swift b/Tests/RastrilloAppleTests/SealedStoreTests.swift
index d4be5cd..63a0d15 100644
--- a/Tests/RastrilloAppleTests/SealedStoreTests.swift
+++ b/Tests/RastrilloAppleTests/SealedStoreTests.swift
@@ -172,4 +172,36 @@ final class SealedStoreTests: XCTestCase {
let lane = await relaunched.entries(lane: "doc:1")
XCTAssertEqual(lane.map(\.id), [entry.id])
}
+ func testSealQuotaRotatesAndResealsInPlace() throws {
+ let keys = ScriptedKeyProvider()
+ let sealed = store(keys: keys)
+ sealed.rotationLimit = 8
+ for _ in 0..<8 { try sealed.write(Data("A".utf8), to: "cache/a") }
+ let old = try XCTUnwrap(keys.storedKey)
+ try sealed.write(Data("B".utf8), to: "cache/b")
+ XCTAssertNotEqual(keys.storedKey, old)
+ XCTAssertEqual(try sealed.read("cache/a"), Data("A".utf8))
+ XCTAssertEqual(try sealed.read("cache/b"), Data("B".utf8))
+ XCTAssertNil(keys.sealState().1?.previous)
+ XCTAssertEqual(keys.sealState().1?.seals, 2, "One reseal and the requested write")
+ let bytes = try Data(contentsOf: file("account-a", "cache/a"))
+ XCTAssertThrowsError(try AES.GCM.open(AES.GCM.SealedBox(combined: bytes), using: old, authenticating: Data("account-a|1|cache/a".utf8)))
+ }
+
+ func testInterruptedRotationReadsBothKeysAndFinishesOnNextWrite() throws {
+ let keys = ScriptedKeyProvider()
+ let sealed = store(keys: keys)
+ try sealed.write(Data("A".utf8), to: "cache/a")
+ let previous = try XCTUnwrap(keys.sealState().1)
+ let new = SymmetricKey(size: .bits256)
+ // Crash just after installing the new key, before any file is resealed.
+ XCTAssertEqual(keys.saveSealState(StoreKeyState(current: new.withUnsafeBytes { Data($0) }, previous: previous.current)), errSecSuccess)
+ let reopened = store(keys: keys)
+ XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8))
+ try reopened.write(Data("B".utf8), to: "cache/b")
+ XCTAssertNil(keys.sealState().1?.previous)
+ XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8))
+ XCTAssertEqual(try reopened.read("cache/b"), Data("B".utf8))
+ }
+
}
diff --git a/Tests/RastrilloNativeTests/OutboxTests.swift b/Tests/RastrilloNativeTests/OutboxTests.swift
index ff23d4c..892b07a 100644
--- a/Tests/RastrilloNativeTests/OutboxTests.swift
+++ b/Tests/RastrilloNativeTests/OutboxTests.swift
@@ -107,6 +107,7 @@ final class OutboxTests: XCTestCase {
try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b\(index)"))
}
let b = await outbox.entries(lane: "doc:b").map(\.id)
+ guard b.count == 4 else { return XCTFail("Expected four durable entries in lane b") }
let inFlight = try await outbox.beginSend(lane: "doc:a", maxBytes: 15)
XCTAssertEqual(inFlight.map(\.id), [a[0]])
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md
index 163ebf0..8165ae0 100644
--- a/docs/testing/k3-evidence.md
+++ b/docs/testing/k3-evidence.md
@@ -18,3 +18,30 @@ companion builds, Go race tests, and Python bundle tests; transcript:
Remaining: complete Docs adoption and crash/convergence evidence, seal-count
rotation, final audit and full gates. Nothing has been merged or deployed.
+
+## Disk stop, 2026-09-24
+
+Work stopped when `df` reported 1.2 GiB free (user's stop threshold: 1.5 GiB).
+Only session-created Docs build outputs and companion DerivedData were removed.
+The inherited `.build` directory in this worktree was preserved.
+
+Additional focused evidence before the stop:
+
+- Seal-count rotation and interrupted-rotation tests: both failed with the
+ rotation call stubbed (`/tmp/k3-rotation-red.log`); all 14 sealed-store tests
+ passed after restoring/fixing rotation (`/tmp/k3-rotation-green-2.log`).
+- Independent-lane retry regression: failed with the first retry starving the
+ next lane (`/tmp/k3-lane-retry-red.log`); all 15 drainer tests then passed
+ (`/tmp/k3-lane-retry-green.log`).
+- Fixed an inherited undeclared AppleTests → RastrilloTestSupport dependency:
+ incremental builds could compile tests against a stale support module.
+- Fixed an inherited test's unchecked empty-array indexing so stub failures
+ produce an assertion instead of terminating the test process.
+
+The latest changes have NOT had another full `make ci`. The previous full
+pass predates rotation and lane-retry fixes. Hub CI on 05bf466 failed while
+unpacking Sparkle because `unzip` is unavailable; no macOS runner exists.
+This branch is WIP and must not merge.
+
+Docs adoption is saved on oficina/docs `feat/k3-outbox-lane`, discussion #69.
+Its evidence file lists unresolved title/lifecycle issues and remaining gates.