| index 6d75f02..0b98ec4 100644 |
| --- a/Sources/RastrilloApple/NativeSealedStore.swift |
| +++ b/Sources/RastrilloApple/NativeSealedStore.swift |
| @@ -92,6 +92,12 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| private let keys: KeyProvider |
| private let lock = NSLock() |
| private let fm = FileManager.default |
| + // One store owns an account directory. Cached material remains protected on |
| + // disk by completeFileProtection; cold opens still distinguish locked/lost. |
| + private var cachedState: StoreKeyState? |
| + private var cachedKey: SymmetricKey? |
| + private var reservedUntil: UInt64 = 0 |
| + private var backupExcluded = false |
| 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] |
| @@ -110,7 +116,12 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| 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 } |
| + do { return try body() } catch { |
| + // A failed write may have committed its reservation. Abandon its entire |
| + // unused tail before retrying, including interrupted key rotation. |
| + cachedState = nil; cachedKey = nil; reservedUntil = 0; backupExcluded = false |
| + throw (error as? BlobStoreError) ?? .unavailable |
| + } |
| } |
| private func hasFiles(_ directory: URL) throws -> Bool { |
| let children: [URL] |
| @@ -124,6 +135,7 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| return false |
| } |
| private func key(create: Bool) throws -> SymmetricKey? { |
| + if let cachedKey { return cachedKey } |
| let (status, key) = keys.copyKey() |
| if status == errSecSuccess, let key { return key } |
| guard status == errSecItemNotFound else { throw BlobStoreError.unavailable } |
| @@ -143,18 +155,29 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| } |
| private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) } |
| private func readKeyState() throws -> StoreKeyState { |
| + if let cachedState { return cachedState } |
| let (status, state) = keys.sealState() |
| guard status == errSecSuccess, let state else { throw BlobStoreError.unavailable } |
| + cachedState = state; cachedKey = SymmetricKey(data: state.current) |
| + reservedUntil = state.seals |
| return state |
| } |
| private func saveKeyState(_ state: StoreKeyState) throws { |
| - guard keys.saveSealState(state) == errSecSuccess else { throw BlobStoreError.unavailable } |
| + var durable = state |
| + durable.seals = max(state.seals, reservedUntil) |
| + guard keys.saveSealState(durable) == errSecSuccess else { throw BlobStoreError.unavailable } |
| + cachedState = state; cachedKey = SymmetricKey(data: state.current) |
| } |
| private func reserveSeal(_ state: inout StoreKeyState) throws { |
| guard state.seals < rotationLimit else { throw BlobStoreError.unavailable } |
| + if state.seals == reservedUntil { |
| + // Persist a high-water mark before using any slot. Relaunch starts at |
| + // that mark, sacrificing the unused tail rather than reusing a quota. |
| + reservedUntil = state.seals + min(1024, rotationLimit - state.seals) |
| + try saveKeyState(state) |
| + } |
| state.seals += 1 |
| - // Count before sealing: a kill can overcount, but can never reuse a quota. |
| - try saveKeyState(state) |
| + cachedState = state |
| } |
| private func open(_ data: Data, path: String, state: StoreKeyState) throws -> Data { |
| let box = try AES.GCM.SealedBox(combined: data) |
| @@ -176,6 +199,7 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| 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) |
| + reservedUntil = 0 |
| try saveKeyState(state) |
| } |
| for file in try files(root) { |
| @@ -200,10 +224,12 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| var state = try readKeyState() |
| try rotateIfNeeded(&state) |
| try reserveSeal(&state) |
| - let key = SymmetricKey(data: state.current) |
| + let key = cachedKey! |
| try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) |
| - var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true |
| - try directory.setResourceValues(values) |
| + if !backupExcluded { |
| + var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true |
| + try directory.setResourceValues(values); backupExcluded = true |
| + } |
| 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) |
| @@ -244,6 +270,7 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| try lock.withLock { try io { |
| let status = keys.deleteKey() |
| guard status == errSecSuccess || status == errSecItemNotFound else { throw BlobStoreError.unavailable } |
| + cachedState = nil; cachedKey = nil; reservedUntil = 0; backupExcluded = false |
| do { try fm.removeItem(at: root) } catch { if !missing(error) { throw error } } |
| } } |
| } |