diff --git a/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift b/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift index ff409ec5..ff2092e8 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift @@ -18,6 +18,9 @@ public enum DatabaseSuspension { /// releases its locks; any in-flight write fails with `SQLITE_INTERRUPT` or /// `SQLITE_ABORT` rather than risking the `0xDEAD10CC` termination. public static func suspend() { + #if os(iOS) + StoreOwnershipLifecycle.shared.suspend() + #endif NotificationCenter.default.post(name: Database.suspendNotification, object: nil) } @@ -25,5 +28,9 @@ public enum DatabaseSuspension { /// databases start accepting writes again. public static func resume() { NotificationCenter.default.post(name: Database.resumeNotification, object: nil) + #if os(iOS) + StoreOwnershipLifecycle.shared.resume() + StoreOwnershipLifecycle.shared.flushPending() + #endif } } diff --git a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift index 3c5988c1..9a3b2e5e 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift @@ -1,6 +1,14 @@ import Foundation import GRDB +extension GRDBClipboardStore { + /// Injectable writer for tests (`DatabaseQueue()` in-memory). + public convenience init(writer: any DatabaseWriter, blobs: BlobStore) { + self.init(writer: writer, blobs: blobs, generationLease: nil) + } + +} + // This class owns the database handle and core query/write surface. Canonical // migrations and row mappings live in focused sibling files. /// SQLite-backed source of truth for clip history (GRDB). @@ -19,6 +27,8 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { /// and the test harness can run statements without widening the API. let writer: any DatabaseWriter private let blobs: BlobStore + /// Retained for the pool's lifetime so recovery cannot relocate a live generation. + private let generationLease: StoreGenerationLease? /// Maintenance-only blob access for same-module engines (orphan sweeps). var blobsForMaintenance: BlobStore { blobs } @@ -33,6 +43,8 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { /// path used by tests and the perf harness. public convenience init(directory: URL, passphrase: String? = nil) throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let generationLease = try StoreGenerationRecovery.openLease( + in: directory, checkingPlaintextConversion: passphrase != nil) let dbPath = directory.appendingPathComponent("gancho.sqlite").path var configuration = Configuration() @@ -76,15 +88,19 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { blobEncryptionKeyData = nil #endif + // Actual writer/reader/snapshot connections retain the generation. + // Closed GRDB configuration/watchdog objects must not over-pin it. + generationLease.prepare(&configuration) let pool = try DatabasePool(path: dbPath, configuration: configuration) + try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( directory: directory.appendingPathComponent("blobs"), encryptionKeyData: blobEncryptionKeyData) try blobStore.encryptPlaintextFilesIfNeeded() + try generationLease.downgrade() self.init( writer: pool, - blobs: blobStore) - try GanchoDatabaseMigrator.make().migrate(pool) + blobs: blobStore, generationLease: generationLease) // NOTE: the cosmetic legacy-preview backfill is deliberately NOT run // here — it scanned image rows inside a write transaction on every // open, taxing cold launch in the app and every extension. Apps call @@ -156,20 +172,10 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { return dbError.resultCode == .SQLITE_NOTADB } - /// Moves an unreadable encrypted database (and its WAL/SHM siblings) aside to - /// a timestamped, collision-resistant `.unreadable-*` name so a fresh store - /// can take its place. - /// Content-addressed blobs are left in place — the fresh database won't - /// reference them, and an orphan sweep reclaims them later. + /// Preserve database/WAL/SHM and sealed blobs (including thumbnails) as + /// one resumable generation, outside the live content-addressed namespace. static func archiveUnreadableStore(in directory: URL) throws { - let fileManager = FileManager.default - let suffix = unreadableStoreArchiveSuffix() - for name in ["gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm"] { - let source = directory.appendingPathComponent(name) - guard fileManager.fileExists(atPath: source.path) else { continue } - let destination = directory.appendingPathComponent("\(name).unreadable-\(suffix)") - try fileManager.moveItem(at: source, to: destination) - } + try StoreGenerationRecovery.archive(in: directory, suffix: unreadableStoreArchiveSuffix()) } static func unreadableStoreArchiveSuffix( @@ -258,10 +264,10 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { } #endif - /// Injectable writer for tests (`DatabaseQueue()` in-memory). - public init(writer: any DatabaseWriter, blobs: BlobStore) { + init(writer: any DatabaseWriter, blobs: BlobStore, generationLease: StoreGenerationLease?) { self.writer = writer self.blobs = blobs + self.generationLease = generationLease } /// Tests call this for in-memory databases; the directory initializer diff --git a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift index cef886dd..99801a32 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift @@ -48,6 +48,8 @@ import GRDB ) throws -> GRDBClipboardStore { try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) + let generationLease = try StoreGenerationRecovery.openLease( + in: directory, checkingPlaintextConversion: true) let dbPath = directory.appendingPathComponent("gancho.sqlite").path var configuration = Configuration() @@ -67,15 +69,18 @@ import GRDB configuration.prepareDatabase { db in try db.usePassphrase(sqlcipherKey) } + generationLease.prepare(&configuration) let pool = try DatabasePool(path: dbPath, configuration: configuration) + try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( directory: directory.appendingPathComponent("blobs"), // The blob key derives from the PLAIN hex string — never the // x'…' literal — or every sealed blob/thumbnail is lost. encryptionKeyData: BlobStore.encryptionKeyData(for: passphrase)) try blobStore.encryptPlaintextFilesIfNeeded() - let store = GRDBClipboardStore(writer: pool, blobs: blobStore) - try store.migrate() + try generationLease.downgrade() + let store = GRDBClipboardStore( + writer: pool, blobs: blobStore, generationLease: generationLease) return store } diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift new file mode 100644 index 00000000..f98a0d28 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift @@ -0,0 +1,44 @@ +import Foundation +import GRDB + +#if SQLITE_HAS_CODEC + import SQLCipher +#else + import SQLite3 +#endif + +/// Pin the actual SQLite connection, not GRDB's longer-lived configuration or +/// dispatch watchdog. SQLite drops this reference only after closing its pager, +/// including delayed close_v2 completion with outstanding statements/backups. +extension StoreGenerationLease { + enum ConnectionFailure: Error { case releasedGeneration, registrationFailed } + + func prepare(_ configuration: inout Configuration) { + configuration.prepareDatabase { [weak self] db in + guard let self else { throw ConnectionFailure.releasedGeneration } + try pin(to: db) + } + } + + func pin(to database: Database) throws { + guard let connection = database.sqliteConnection else { + throw ConnectionFailure.registrationFailed + } + // Per-connection private name prevents accidental replacement by other + // application functions. No persisted schema or query uses this function. + let name = "gancho_generation_" + UUID().uuidString.replacingOccurrences(of: "-", with: "") + let retained = Unmanaged.passRetained(self).toOpaque() + let result = sqlite3_create_function_v2( + connection, name, 0, SQLITE_UTF8 | SQLITE_DIRECTONLY, retained, + { context, _, _ in sqlite3_result_null(context) }, nil, nil, + { pointer in + guard let pointer else { return } + // Drop only this connection's reference. Never release shared + // generation ownership beneath sibling readers or snapshots. + Unmanaged.fromOpaque(pointer).release() + }) + // SQLite invokes xDestroy on registration failure too. It owns the + // retained pointer after the call; releasing it here would double-free. + guard result == SQLITE_OK else { throw ConnectionFailure.registrationFailed } + } +} diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift new file mode 100644 index 00000000..1d5ce088 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -0,0 +1,150 @@ +import Darwin +import Foundation + +/// An open database registers its filesystem generation durably. Recovery +/// requires exclusive logical ownership, not a file lock held during suspension. +final class StoreGenerationLease: @unchecked Sendable { + private let ownership: StoreProcessOwnership + let directory: URL + + init(in directory: URL, exclusive: Bool) throws { + let acquired = try StoreProcessOwnership.acquire( + in: directory, scope: .generation, exclusive: exclusive) + guard let ownership = acquired else { throw StoreProcessOwnership.Failure.busy } + self.ownership = ownership + self.directory = directory.standardizedFileURL.resolvingSymlinksInPath() + } + + func downgrade() throws { try ownership.downgrade() } + func release() { ownership.release() } +} + +/// Preserves a whole database/blob/thumbnail generation before creating a new +/// namespace. The journal makes every member move idempotent after interruption. +/// A partial or ambiguous generation fails closed; it is never opened as empty. +enum StoreGenerationRecovery { + static let journalName = ".store-recovery.json" + static let members = [ + "gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", + "gancho.sqlite.encrypting", "blobs" + ] + + private struct Journal: Codable { + var archive: String + var members: [String] + } + + static func openLease( + in directory: URL, checkingPlaintextConversion: Bool = false + ) throws -> StoreGenerationLease { + var lease = try StoreGenerationLease(in: directory, exclusive: false) + let journalURL = directory.appendingPathComponent(journalName) + let pending = FileManager.default.fileExists(atPath: journalURL.path) + let converting = try checkingPlaintextConversion && needsPlaintextConversion(in: directory) + if pending || converting { + lease.release() + lease = try StoreGenerationLease(in: directory, exclusive: true) + // Recheck after exclusive acquisition: another opener may have + // completed recovery while this shared registration was released. + if FileManager.default.fileExists(atPath: journalURL.path) { + try resume(in: directory) + } + if !checkingPlaintextConversion { try lease.downgrade() } + } + return lease + } + + private static func needsPlaintextConversion(in directory: URL) throws -> Bool { + let database = directory.appendingPathComponent("gancho.sqlite") + let manager = FileManager.default + if !manager.fileExists(atPath: database.path) { + return true // Creation can race a plaintext opener too. + } + let handle = try FileHandle(forReadingFrom: database) + defer { try? handle.close() } + return try handle.read(upToCount: 16) == Data("SQLite format 3\u{0}".utf8) + } + + static func archive( + in directory: URL, suffix: String, + afterMove: (String) throws -> Void = { _ in } + ) throws { + let lease = try StoreGenerationLease(in: directory, exclusive: true) + defer { withExtendedLifetime(lease) {} } + let manager = FileManager.default + let journalURL = directory.appendingPathComponent(journalName) + if manager.fileExists(atPath: journalURL.path) { + try resume(in: directory, afterMove: afterMove) + return + } + let present = members.filter { + manager.fileExists(atPath: directory.appendingPathComponent($0).path) + } + guard present.contains("gancho.sqlite") else { throw CocoaError(.fileReadNoSuchFile) } + let archiveName = ".unreadable-\(suffix)" + let archive = directory.appendingPathComponent(archiveName, isDirectory: true) + try manager.createDirectory( + at: archive, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + let journal = Journal(archive: archiveName, members: present) + // The journal's bytes must be durable before its name is: a published + // but empty journal would fail every later open closed. + let staged = directory.appendingPathComponent(journalName + ".staged") + try JSONEncoder().encode(journal).write(to: staged) + let handle = try FileHandle(forWritingTo: staged) + try handle.synchronize() + try handle.close() + try AtomicFileReplace.publish(staged: staged, as: journalURL) + // The journal and the archive directory must be durable entries before + // the first member moves, or a crash could lose the record of the move. + try synchronizeDirectory(directory) + try resume(in: directory, afterMove: afterMove) + } + + static func resume( + in directory: URL, afterMove: (String) throws -> Void = { _ in } + ) throws { + let manager = FileManager.default + let journalURL = directory.appendingPathComponent(journalName) + guard manager.fileExists(atPath: journalURL.path) else { return } + let journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: journalURL)) + guard journal.archive.hasPrefix(".unreadable-"), + !journal.archive.contains("/"), !journal.archive.contains(".."), + Set(journal.members).count == journal.members.count, + journal.members.contains("gancho.sqlite"), + journal.members.allSatisfy({ members.contains($0) }) + else { throw CocoaError(.fileReadCorruptFile) } + let archive = directory.appendingPathComponent(journal.archive, isDirectory: true) + let attributes = try archive.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard attributes.isDirectory == true, attributes.isSymbolicLink != true + else { throw CocoaError(.fileReadCorruptFile) } + for name in journal.members { + let source = directory.appendingPathComponent(name) + let destination = archive.appendingPathComponent(name) + let sourceExists = manager.fileExists(atPath: source.path) + let archived = manager.fileExists(atPath: destination.path) + guard sourceExists != archived else { throw CocoaError(.fileReadCorruptFile) } + if sourceExists { + try manager.moveItem(at: source, to: destination) + try afterMove(name) + } + } + // Every rename must reach disk before the journal that describes it is + // dropped; afterwards the journal removal itself is made durable. + try synchronizeDirectory(archive) + try synchronizeDirectory(directory) + try manager.removeItem(at: journalURL) + try synchronizeDirectory(directory) + } + + /// Flushes a directory's entries. `F_FULLFSYNC` asks the drive to commit + /// its cache too; volumes that do not support it fall back to `fsync`. + static func synchronizeDirectory(_ directory: URL) throws { + let descriptor = open(directory.path, O_RDONLY) + guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(descriptor) } + if fcntl(descriptor, F_FULLFSYNC) == 0 { return } + guard fsync(descriptor) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } +} diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift new file mode 100644 index 00000000..86f905a3 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift @@ -0,0 +1,166 @@ +import Darwin +import Foundation +import GRDB + +/// Reuse-proof identity for ownership claims, and the ledger schema that +/// stores it. A PID alone cannot prove a claim stale: PIDs restart at every +/// boot and wrap within one, so a jetsam-killed claimant's PID can belong to an +/// unrelated live process. The boot session and process start time can. +extension StoreProcessOwnership { + /// How the current process judges another claim's liveness. + struct Liveness: Sendable { + /// This boot's `kern.bootsessionuuid`; empty when unavailable. + var bootSession: String + /// Start time of a live PID, microseconds since 1970; nil when unknown. + var startTime: @Sendable (Int32) -> Int64? + + static var system: Self { + Self(bootSession: currentBootSession, startTime: processStartTime) + } + } + + static let currentBootSession: String = readBootSession() + static let currentStartTime: Int64 = processStartTime(getpid()) ?? 0 + + static let ownerQuery = """ + SELECT o.token AS token, o.processToken AS processToken, o.pid AS pid, + o.scope AS scope, o.exclusive AS exclusive, + i.bootSession AS bootSession, i.startTime AS startTime + FROM owner o LEFT JOIN owner_identity i ON i.token = o.token + """ + + /// Fail-closed staleness. Only positive evidence removes a claim: + /// - a different boot session (claims never survive a reboot), or + /// - `ESRCH` for the PID, or + /// - a PID this process may signal whose start time differs from the one + /// recorded with the claim (the PID was reused). + /// Unknown stamps (version-1 claims, unavailable sysctls) and processes that + /// cannot be inspected keep their claim. + static func isStale( + _ claim: Identity, probe: (Int32) -> ProcessState, liveness: Liveness + ) -> Bool { + if !claim.bootSession.isEmpty, !liveness.bootSession.isEmpty, + claim.bootSession != liveness.bootSession + { + return true + } + switch probe(claim.pid) { + case .exited: return true + case .unverifiable: return false + case .live: + guard claim.startTime > 0, let observed = liveness.startTime(claim.pid) else { + return false + } + return observed != claim.startTime + } + } + + /// Identity rows only have meaning beside their claim; both are written in + /// one transaction, so an unmatched one is a released claim's remainder. + static func removeOrphanIdentities(in db: Database) throws { + try db.execute( + sql: "DELETE FROM owner_identity WHERE token NOT IN (SELECT token FROM owner)") + } + + static let ownerSchema = """ + CREATE TABLE owner ( + token TEXT PRIMARY KEY NOT NULL, + processToken TEXT NOT NULL, + pid INTEGER NOT NULL CHECK (pid > 0 AND pid <= 2147483647), + scope TEXT NOT NULL CHECK (scope IN ('generation', 'blob')), + exclusive INTEGER NOT NULL CHECK (exclusive IN (0, 1)) + ) + """ + + static let identitySchema = """ + CREATE TABLE owner_identity ( + token TEXT PRIMARY KEY NOT NULL, + bootSession TEXT NOT NULL, + startTime INTEGER NOT NULL CHECK (startTime >= 0) + ) + """ + + /// Version 1 held only `owner`. Version 2 adds `owner_identity`; version-1 + /// claims stay valid and are judged by their PID alone. A missing, unknown + /// or altered ledger is never recreated: that could erase knowledge of an + /// active logical owner. + static func prepareSchema(_ db: Database) throws { + let version = try Int.fetchOne(db, sql: "PRAGMA user_version") ?? 0 + let owner = try tableSQL("owner", in: db) + let identity = try tableSQL("owner_identity", in: db) + switch version { + case 0: + let query = "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + let objects = try Int.fetchOne(db, sql: query) ?? 0 + guard owner == nil, identity == nil, objects == 0 else { + throw Failure.malformedMetadata + } + try db.execute(sql: ownerSchema) + try db.execute(sql: identitySchema) + try db.execute(sql: "PRAGMA user_version = 2") + case 1: + guard sameSchema(owner, ownerSchema), identity == nil else { + throw Failure.malformedMetadata + } + try db.execute(sql: identitySchema) + try db.execute(sql: "PRAGMA user_version = 2") + case 2: + guard sameSchema(owner, ownerSchema), sameSchema(identity, identitySchema) else { + throw Failure.malformedMetadata + } + default: + throw Failure.malformedMetadata + } + } + + private static func tableSQL(_ name: String, in db: Database) throws -> String? { + try String.fetchOne( + db, sql: "SELECT sql FROM sqlite_master WHERE name = ? AND type = 'table'", + arguments: [name]) + } + + private static func sameSchema(_ stored: String?, _ expected: String) -> Bool { + guard let stored else { return false } + return stored.split(whereSeparator: \.isWhitespace) + == expected.split(whereSeparator: \.isWhitespace) + } + + private static func readBootSession() -> String { + var size = 0 + guard sysctlbyname("kern.bootsessionuuid", nil, &size, nil, 0) == 0, size > 0 else { + return "" + } + var buffer = [UInt8](repeating: 0, count: size) + guard sysctlbyname("kern.bootsessionuuid", &buffer, &size, nil, 0) == 0 else { + return "" + } + guard let value = String(bytes: buffer.prefix { $0 != 0 }, encoding: .utf8) else { + return "" + } + return UUID(uuidString: value) == nil ? "" : value + } + + /// `KERN_PROC_PID` start time. Nil when the process is gone or the sandbox + /// withholds it, which callers treat as "unknown", never as "different". + static func processStartTime(_ pid: Int32) -> Int64? { + guard pid > 0 else { return nil } + var info = kinfo_proc() + var size = MemoryLayout.stride + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + let status = name.withUnsafeMutableBufferPointer { mib in + sysctl(mib.baseAddress, u_int(mib.count), &info, &size, nil, 0) + } + guard status == 0, size >= MemoryLayout.size, info.kp_proc.p_pid == pid + else { return nil } + let start = info.kp_proc.p_un.__p_starttime + let micros = Int64(start.tv_sec) * 1_000_000 + Int64(start.tv_usec) + return micros > 0 ? micros : nil + } +} + +extension StoreProcessOwnership.Identity { + /// A stamp is either unknown (empty / 0) or well formed. + var hasValidStamp: Bool { + startTime >= 0 && (bootSession.isEmpty || UUID(uuidString: bootSession) != nil) + } +} diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift new file mode 100644 index 00000000..c38f85f9 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -0,0 +1,285 @@ +import Darwin +import Foundation +import GRDB + +/// Durable logical ownership, not a kernel lock. Transactions contain only +/// metadata; no transaction/file lock survives a return or an async suspension. +/// The sidecar holds no key, clip content, hash or path from a clipboard item. +final class StoreProcessOwnership: @unchecked Sendable { + enum Scope: String, Sendable, Equatable { case generation, blob } + enum ProcessState: Sendable, Equatable { case live, exited, unverifiable } + enum Failure: Error { case busy, suspended, malformedMetadata } + + static let fileName = ".store-ownership.sqlite" + static let processToken = UUID().uuidString + struct Identity: Sendable { + var pid: Int32 + var token: String + /// `kern.bootsessionuuid` when the claim was made; empty when unknown. + var bootSession = "" + /// Process start in microseconds since 1970; 0 when unknown. + var startTime: Int64 = 0 + static var current: Self { + Self( + pid: getpid(), token: processToken, bootSession: currentBootSession, + startTime: currentStartTime) + } + } + /// A validated ledger row. + private struct OwnerClaim { + var stamp: Identity + var scope: Scope + var exclusive: Bool + } + private struct Owner: Decodable, FetchableRecord { + var token: String + var processToken: String + var pid: Int64 + var scope: String + var exclusive: Int + /// Nil for a claim migrated from the version-1 ledger. + var bootSession: String? + var startTime: Int64? + + /// Any malformed field fails the whole ledger closed. + func validated() throws -> OwnerClaim { + guard pid > 0, pid <= Int64(Int32.max), UUID(uuidString: token) != nil, + UUID(uuidString: processToken) != nil, + let parsedScope = Scope(rawValue: self.scope), + exclusive == 0 || exclusive == 1 + else { throw Failure.malformedMetadata } + let stamp = Identity( + pid: Int32(pid), token: processToken, bootSession: bootSession ?? "", + startTime: startTime ?? 0) + guard stamp.hasValidStamp else { throw Failure.malformedMetadata } + return OwnerClaim(stamp: stamp, scope: parsedScope, exclusive: exclusive == 1) + } + } + private let queue: DatabaseQueue + private let lifecycle: StoreOwnershipLifecycle + private let directory: URL + private let token: String + private let owner: String + private let mutex = NSLock() + private var released = false + private var exclusive: Bool + + private init( + queue: DatabaseQueue, directory: URL, token: String, owner: String, + exclusive: Bool, lifecycle: StoreOwnershipLifecycle + ) { + self.queue = queue + self.lifecycle = lifecycle + self.directory = directory + self.token = token + self.owner = owner + self.exclusive = exclusive + } + + static func acquire( + in directory: URL, scope: Scope, exclusive: Bool, + identity: Identity = .current, + probe: @Sendable (Int32) -> ProcessState = processState, + liveness: Liveness = .system, + lifecycle: StoreOwnershipLifecycle = .shared + ) throws -> StoreProcessOwnership? { + let directory = directory.standardizedFileURL.resolvingSymlinksInPath() + let pid = identity.pid + let owner = identity.token + guard pid > 0, UUID(uuidString: owner) != nil, identity.hasValidStamp + else { throw Failure.malformedMetadata } + return try lifecycle.withActive { + let queue = try makeQueue(in: directory) + var retained = false + defer { if !retained { lifecycle.closeOrDefer(queue) } } + let token = UUID().uuidString + let acquired = try queue.write { db in + try prepareSchema(db) + try lifecycle.drainPending(in: db, directory: directory) + let rows = try Owner.fetchAll(db, sql: ownerQuery) + for row in rows { + let other = try row.validated() + if isStale(other.stamp, probe: probe, liveness: liveness) { + try db.execute( + sql: "DELETE FROM owner WHERE token = ?", arguments: [row.token]) + continue + } + // Recovery is exclusive over both scopes. Blob work also + // checks recovery, including injected stores without a pin. + let recovering = scope == .generation && exclusive + let otherRecovering = other.scope == .generation && other.exclusive + if recovering || otherRecovering + || (scope == .blob && other.scope == .blob) + { + return false + } + } + try removeOrphanIdentities(in: db) + try db.execute( + sql: "INSERT INTO owner VALUES (?, ?, ?, ?, ?)", + arguments: [token, owner, pid, scope.rawValue, exclusive ? 1 : 0]) + try db.execute( + sql: "INSERT INTO owner_identity VALUES (?, ?, ?)", + arguments: [token, identity.bootSession, identity.startTime]) + return true + } + lifecycle.didCommitPending(in: directory) + guard acquired else { return nil } + retained = true + return StoreProcessOwnership( + queue: queue, directory: directory, token: token, owner: owner, + exclusive: exclusive, lifecycle: lifecycle) + } + } + + func downgrade() throws { + mutex.lock() + defer { mutex.unlock() } + guard !released else { throw Failure.malformedMetadata } + // A shared claim has nothing to downgrade. Skipping the write keeps a + // steady-state open from failing on a busy or suspended ledger. + guard exclusive else { return } + try lifecycle.withActive { + try queue.write { db in + try db.execute( + sql: "UPDATE owner SET exclusive = 0 WHERE token = ? AND processToken = ?", + arguments: [token, owner]) + guard db.changesCount == 1 else { throw Failure.malformedMetadata } + } + } + exclusive = false + } + + func release() { + mutex.lock() + defer { mutex.unlock() } + guard !released else { return } + released = true + // Only a finished operation enters pending release. A suspended or + // interrupted metadata write preserves the claim until a safe retry. + lifecycle.release( + token: token, owner: owner, in: directory, queue: queue) + } + + deinit { release() } + + static func processState(_ pid: Int32) -> ProcessState { + guard pid > 0 else { return .unverifiable } + // Signal zero sends no signal. ESRCH is the only documented proof of + // absence; sandbox/permission errors keep the claim. PID reuse is + // judged separately by the boot session and process start time. + if kill(pid, 0) == 0 { return .live } + return errno == ESRCH ? .exited : .unverifiable + } + + fileprivate static func makeQueue(in directory: URL) throws -> DatabaseQueue { + var configuration = Configuration() + configuration.busyMode = .timeout(0.1) + configuration.journalMode = .wal + #if os(iOS) + configuration.automaticMemoryManagement = false + configuration.observesSuspensionNotifications = true + #endif + let path = directory.appendingPathComponent(fileName).path + return try DatabaseQueue(path: path, configuration: configuration) + } +} + +/// Serializes only short coordinator transactions against the iOS suspension +/// boundary. suspend() cannot return with such a transaction still in flight; +/// opening a new observer after suspension is rejected too. Logical ownership +/// remains durable while the process is suspended, without holding a file lock. +final class StoreOwnershipLifecycle: @unchecked Sendable { + static let shared = StoreOwnershipLifecycle() + private struct Release: Hashable { + var token: String + var owner: String + } + private let mutex = NSLock() + private var suspended = false + private var pending: [URL: Set] = [:] + private var pendingQueues: [URL: [DatabaseQueue]] = [:] + private var deferredClose: [DatabaseQueue] = [] + + func withActive(_ body: () throws -> T) throws -> T { + mutex.lock() + defer { mutex.unlock() } + guard !suspended else { throw StoreProcessOwnership.Failure.suspended } + let closing = deferredClose + deferredClose = [] + for queue in closing { closeOrDefer(queue) } + return try body() + } + + func suspend() { + mutex.lock() + suspended = true + mutex.unlock() + } + + func resume() { + mutex.lock() + suspended = false + mutex.unlock() + } + + fileprivate func drainPending(in db: Database, directory: URL) throws { + // Caller already holds the lifecycle fence and a writer transaction. + // The fence prevents concurrent additions. Acknowledge this exact + // pending set only after the writer call has successfully committed. + for release in pending[directory] ?? [] { + try db.execute( + sql: "DELETE FROM owner WHERE token = ? AND processToken = ?", + arguments: [release.token, release.owner]) + } + try StoreProcessOwnership.removeOrphanIdentities(in: db) + } + + fileprivate func didCommitPending(in directory: URL) { + pending.removeValue(forKey: directory) + for queue in pendingQueues.removeValue(forKey: directory) ?? [] { + closeOrDefer(queue) + } + } + + fileprivate func closeOrDefer(_ queue: DatabaseQueue) { + // The caller holds the active fence. WAL checkpoint-on-close must + // finish here too, not in a later property deinitialization. + do { try queue.close() } catch { deferredClose.append(queue) } + } + + fileprivate func release( + token: String, owner: String, in directory: URL, queue: DatabaseQueue + ) { + mutex.lock() + defer { mutex.unlock() } + pending[directory, default: []].insert(Release(token: token, owner: owner)) + pendingQueues[directory, default: []].append(queue) + guard !suspended else { return } + flush(in: directory, queue: queue) + } + + private func flush(in directory: URL, queue: DatabaseQueue) { + do { + try queue.write { db in try drainPending(in: db, directory: directory) } + didCommitPending(in: directory) + } catch { + // No bytes are deleted and the exact known-finished claim is retried. + } + } + + func flushPending() { + mutex.lock() + defer { mutex.unlock() } + guard !suspended else { return } + let closing = deferredClose + deferredClose = [] + for queue in closing { closeOrDefer(queue) } + for directory in Array(pending.keys).prefix(128) { + if let queue = try? StoreProcessOwnership.makeQueue(in: directory) { + flush(in: directory, queue: queue) + closeOrDefer(queue) + } + } + } +} diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift index 9f5c623c..3ff02653 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift @@ -5,11 +5,13 @@ import Testing @testable import GanchoKit /// The seam just bridges to GRDB's notifications; the actual lock-release -/// behavior is GRDB's, verified on device. Here we prove we post the right ones. -@Suite("DatabaseSuspension — GRDB notification seam") +/// behavior remains a physical-device qualification gate. Here we prove the +/// notification seam and restore lifecycle state after every test. +@Suite("DatabaseSuspension — GRDB notification seam", .serialized) struct DatabaseSuspensionTests { @Test("suspend() posts GRDB's suspend notification") func suspendPostsNotification() async { + defer { DatabaseSuspension.resume() } await confirmation { confirmed in let token = NotificationCenter.default.addObserver( forName: Database.suspendNotification, object: nil, queue: nil diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift index 7d07fc01..eb1af36e 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift @@ -181,7 +181,7 @@ import Testing let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) #expect( - names.contains { $0.hasPrefix("gancho.sqlite.unreadable-") }, + names.contains { $0.hasPrefix(".unreadable-") }, "the unreadable database is moved aside, never deleted") } @@ -219,7 +219,7 @@ import Testing } let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) #expect( - !names.contains { $0.hasPrefix("gancho.sqlite.unreadable-") }, + !names.contains { $0.hasPrefix(".unreadable-") }, "a non-fresh failure must leave the store untouched") } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift index a02525a1..7cdc0d62 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift @@ -177,7 +177,7 @@ import Testing let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) #expect( - names.contains { $0.hasPrefix("gancho.sqlite.unreadable-") }, + names.contains { $0.hasPrefix(".unreadable-") }, "raw-key adoption must archive the unreachable store before starting fresh") } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift new file mode 100644 index 00000000..5da15fe0 --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift @@ -0,0 +1,66 @@ +import Foundation +import GRDB +import Testing + +@testable import GanchoKit + +// A stored weak reference works on the package's Swift 6.2 minimum too; +// unlike a weak local, it does not trigger newer compiler mutability warnings. +private final class WeakGenerationFacade { + weak var value: GRDBClipboardStore? + init(_ value: GRDBClipboardStore?) { self.value = value } +} + +@Suite("Generation pins follow actual SQLite connection lifetime") +struct StoreGenerationConnectionPinTests { + private func directory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } + + @Test("Retained writer blocks recovery; closed writer/configuration does not over-pin") + func writerConnectionLifetime() throws { + let root = directory() + defer { try? FileManager.default.removeItem(at: root) } + var store: GRDBClipboardStore? = try GRDBClipboardStore(directory: root) + let facade = WeakGenerationFacade(store) + let writer = try #require(store?.writer) + let configuration = writer.configuration + store = nil + #expect(facade.value == nil) + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "blocked") + } + try writer.close() + try withExtendedLifetime(writer) { + // Keep both writer and its configuration alive. The actual connection + // has closed, so configuration/watchdog retention must not hold a pin. + try StoreGenerationRecovery.archive(in: root, suffix: "closed") + let preserved = root.appendingPathComponent(".unreadable-closed/gancho.sqlite") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + let forbidden = root.appendingPathComponent("unpinned.sqlite") + #expect(throws: StoreGenerationLease.ConnectionFailure.self) { + try DatabaseQueue(path: forbidden.path, configuration: configuration) + } + } + } + + @Test("Independent snapshot stays pinned after its originating pool closes") + func snapshotConnectionLifetime() throws { + let root = directory() + defer { try? FileManager.default.removeItem(at: root) } + var store: GRDBClipboardStore? = try GRDBClipboardStore(directory: root) + let facade = WeakGenerationFacade(store) + let pool = try #require(store?.writer as? DatabasePool) + let snapshot = try pool.makeSnapshot() + store = nil + #expect(facade.value == nil) + try pool.close() + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "blocked") + } + try snapshot.close() + try StoreGenerationRecovery.archive(in: root, suffix: "snapshot-closed") + let preserved = root.appendingPathComponent(".unreadable-snapshot-closed/gancho.sqlite") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + } +} diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift new file mode 100644 index 00000000..d839a890 --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing + +@_spi(GanchoInternal) @testable import GanchoKit + +@Suite("Store generation recovery — preservation and interrupted moves") +struct StoreGenerationRecoveryTests { + private struct Interrupted: Error {} + + private func fixture() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("blobs/thumbnails"), withIntermediateDirectories: true) + for name in [ + "gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "gancho.sqlite.encrypting", + "blobs/hash", "blobs/thumbnails/hash.png" + ] { + try Data(name.utf8).write(to: root.appendingPathComponent(name)) + } + return root + } + + @Test("Every interrupted member move resumes without mixing a new generation") + func resumesEachMove() throws { + for interruptedName in StoreGenerationRecovery.members { + let root = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: Interrupted.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "fixture") { + if $0 == interruptedName { throw Interrupted() } + } + } + let journal = root.appendingPathComponent(StoreGenerationRecovery.journalName) + #expect(FileManager.default.fileExists(atPath: journal.path)) + let lease = try StoreGenerationRecovery.openLease(in: root) + defer { lease.release() } + let archived = root.appendingPathComponent(".unreadable-fixture") + for member in StoreGenerationRecovery.members { + let sourceExists = FileManager.default.fileExists( + atPath: root.appendingPathComponent(member).path) + let archivedExists = FileManager.default.fileExists( + atPath: archived.appendingPathComponent(member).path) + #expect(!sourceExists && archivedExists) + } + let blob = try Data(contentsOf: archived.appendingPathComponent("blobs/hash")) + let thumbnail = try Data( + contentsOf: archived.appendingPathComponent("blobs/thumbnails/hash.png")) + #expect(blob == Data("blobs/hash".utf8)) + #expect(thumbnail == Data("blobs/thumbnails/hash.png".utf8)) + } + } + + @Test("An active generation blocks recovery across independent coordinator connections") + func activeGenerationCannotBeMoved() throws { + let root = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let shared = try StoreGenerationRecovery.openLease(in: root) + #expect(throws: (any Error).self) { + try StoreGenerationRecovery.archive(in: root, suffix: "blocked") + } + let original = try Data(contentsOf: root.appendingPathComponent("gancho.sqlite")) + #expect(original == Data("gancho.sqlite".utf8)) + shared.release() + try StoreGenerationRecovery.archive(in: root, suffix: "allowed") + let preserved = root.appendingPathComponent(".unreadable-allowed/blobs/hash") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + } + + @Test("Plaintext and fresh namespace initialization require exclusive ownership") + func conversionCannotMoveAnActivePool() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fresh = try StoreGenerationRecovery.openLease( + in: root, checkingPlaintextConversion: true) + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.openLease(in: root) + } + let header = Data("SQLite format 3\u{0}".utf8) + try header.write(to: root.appendingPathComponent("gancho.sqlite")) + try fresh.downgrade() + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.openLease(in: root, checkingPlaintextConversion: true) + } + fresh.release() + let conversion = try StoreGenerationRecovery.openLease( + in: root, checkingPlaintextConversion: true) + defer { conversion.release() } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.openLease(in: root) + } + } + + #if SQLITE_HAS_CODEC + @Test("Fresh-key binary recapture is readable; old generation survives maintenance") + func binaryRecovery() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let oldKey = String(repeating: "a1", count: 32) + let newKey = String(repeating: "b2", count: 32) + let payload = Data("old-generation-binary-payload".utf8) + let original = ClipItem(kind: .image, preview: "fixture", contentHash: "original") + var old: GRDBClipboardStore? = try GRDBClipboardStore( + directory: root, passphrase: oldKey) + let content = ClipContent.binary(data: payload, typeIdentifier: "public.data") + try await old?.insert(original, content: content) + old = nil + let blobName = GanchoArchive.sha256(payload) + let oldBlob = root.appendingPathComponent("blobs/\(blobName)") + let oldBytes = try Data(contentsOf: oldBlob) + let recovered = try GRDBClipboardStore.openEncrypted( + directory: root, key: newKey, keyIsFresh: true) + let recaptured = ClipItem(kind: .image, preview: "fixture", contentHash: "recaptured") + try await recovered.insert(recaptured, content: content) + #expect(try await recovered.content(for: recaptured.id) == content) + _ = try await recovered.removeOrphanedBlobs() + let children = try FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil) + let archivedGeneration = children.first { + $0.lastPathComponent.hasPrefix(".unreadable-") + } + let archive = try #require(archivedGeneration) + let archivedBlob = archive.appendingPathComponent("blobs/\(blobName)") + let archivedBytes = try Data(contentsOf: archivedBlob) + #expect(archivedBytes == oldBytes) + let preserved = try GRDBClipboardStore(directory: archive, passphrase: oldKey) + #expect(try await preserved.content(for: original.id) == content) + } + #endif + + @Test("Archiving a directory without a database leaves no empty archive behind") + func missingDatabaseLeavesNoArchive() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: CocoaError.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "empty") + } + let entries = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(!entries.contains { $0.hasPrefix(".unreadable-") }) + #expect(!entries.contains(StoreGenerationRecovery.journalName)) + } +} diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift new file mode 100644 index 00000000..7156beab --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift @@ -0,0 +1,300 @@ +import Foundation +import GRDB +import Testing + +@testable import GanchoKit + +@Suite("Durable ownership — suspension, identity, and crash boundaries") +struct StoreProcessOwnershipTests { + private func directory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + @Test("Idle ownership has no transaction lock and its coordinator is WAL") + func idleClaimIsLogical() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false) + let held = try #require(heldClaim) + defer { held.release() } + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + let mode = try queue.read { db in try String.fetchOne(db, sql: "PRAGMA journal_mode") } + #expect(mode == "wal") + // A completely independent writer can commit while the logical pin is + // retained. A retained SQLite write transaction would prevent it; + // removal of the independent flock protocol is a source-level check. + try queue.write { db in try db.execute(sql: "CREATE TABLE independent (id INTEGER)") } + let blocked = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true) + #expect(blocked == nil) + blocked?.release() + } + + @Test("Recovery excludes blob work and registration until it downgrades") + func unifiedConflictBoundary() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true) + let held = try #require(heldClaim) + defer { held.release() } + let blob = try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) + let opener = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false) + #expect(blob == nil && opener == nil) + blob?.release() + opener?.release() + try held.downgrade() + let allowedClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true) + let allowed = try #require(allowedClaim) + allowed.release() + } + + @Test("Suspended, unverifiable, and reused PIDs are retained; only proven exit reclaims") + func conservativeIdentity() throws { + for state in [StoreProcessOwnership.ProcessState.live, .unverifiable, .exited] { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false, + identity: .init(pid: 42, token: UUID().uuidString), probe: { _ in .live }) + let held = try #require(heldClaim) + defer { held.release() } + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in state }) + #expect((contender != nil) == (state == .exited)) + contender?.release() + } + } + + @Test("A delayed old release cannot remove the next operation's token") + func exactReleaseIdentity() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let oldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: UUID().uuidString)) + let old = try #require(oldClaim) + let newerClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, probe: { _ in .exited }) + let newer = try #require(newerClaim) + defer { newer.release() } + old.release() + old.release() + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, probe: { _ in .live }) + #expect(contender == nil) + contender?.release() + } + + @Test("Lifecycle rejects late observers and retries finished-operation releases") + func lifecycleStateMachine() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let lifecycle = StoreOwnershipLifecycle() + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let held = try #require(heldClaim) + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + lifecycle.suspend() + held.release() + let retained = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } + #expect(retained == 1) + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + } + lifecycle.resume() + lifecycle.flushPending() + let cleared = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } + #expect(cleared == 0) + let nextClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let next = try #require(nextClaim) + next.release() + } + + @Test("An interrupted release keeps its token until the next committed metadata write") + func retryFailedRelease() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let lifecycle = StoreOwnershipLifecycle() + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let held = try #require(heldClaim) + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let competing = try DatabaseQueue(path: coordinator.path) + try competing.writeWithoutTransaction { db in + try db.execute(sql: "BEGIN IMMEDIATE") + defer { try? db.execute(sql: "ROLLBACK") } + held.release() + #expect(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") == 1) + } + let nextClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let next = try #require(nextClaim) + next.release() + let remaining = try competing.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } + #expect(remaining == 0) + } + + @Test("Missing or unknown versioned coordinator schema fails closed") + func damagedCoordinatorIsNotReset() throws { + for version in [1, 2, 3] { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + try queue.write { db in try db.execute(sql: "PRAGMA user_version = \(version)") } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) + } + let query = "SELECT COUNT(*) FROM sqlite_master WHERE name = 'owner'" + let objects = try queue.read { db in try Int.fetchOne(db, sql: query) } + #expect(objects == 0) + } + } + + @Test("Invalid process identities never become reclamation candidates") + func invalidIdentity() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 0, token: UUID().uuidString)) + } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: "not-a-process-identity")) + } + } + + @Test("A claim from an earlier boot is stale even when its PID looks alive") + func earlierBootClaimIsReclaimed() throws { + let bootA = UUID().uuidString + for (currentBoot, reclaimed) in [(UUID().uuidString, true), (bootA, false), ("", false)] { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: UUID().uuidString, bootSession: bootA)) + let held = try #require(heldClaim) + defer { held.release() } + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, probe: { _ in .live }, + liveness: .init(bootSession: currentBoot, startTime: { _ in nil })) + #expect((contender != nil) == reclaimed) + contender?.release() + } + } + + @Test("A reused PID is detected by start time only when the process can be inspected") + func reusedPIDIsReclaimed() throws { + let boot = UUID().uuidString + let cases: [(StoreProcessOwnership.ProcessState, Int64?, Bool)] = [ + (.live, 2_000, true), // same PID, different process + (.live, 1_000, false), // the claimant itself + (.live, nil, false), // start time withheld + (.unverifiable, 2_000, false) // cannot signal it: never inferred + ] + for (state, observed, reclaimed) in cases { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false, + identity: .init( + pid: 42, token: UUID().uuidString, bootSession: boot, startTime: 1_000)) + let held = try #require(heldClaim) + defer { held.release() } + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in state }, + liveness: .init(bootSession: boot, startTime: { _ in observed })) + #expect((contender != nil) == reclaimed) + contender?.release() + } + } + + @Test("A version-1 ledger migrates in place and keeps its claims") + func versionOneLedgerMigrates() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + try queue.write { db in + try db.execute(sql: StoreProcessOwnership.ownerSchema) + try db.execute( + sql: "INSERT INTO owner VALUES (?, ?, 42, 'generation', 0)", + arguments: [UUID().uuidString, UUID().uuidString]) + try db.execute(sql: "PRAGMA user_version = 1") + } + // The legacy claim has no identity stamp, so only its PID judges it. + let blocked = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in .live }, + liveness: .init(bootSession: UUID().uuidString, startTime: { _ in 7 })) + #expect(blocked == nil) + blocked?.release() + let (version, identityTables) = try queue.read { db in + ( + try Int.fetchOne(db, sql: "PRAGMA user_version"), + try Int.fetchOne( + db, sql: "SELECT COUNT(*) FROM sqlite_master WHERE name = 'owner_identity'") + ) + } + #expect(version == 2 && identityTables == 1) + let reclaimedClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in .exited }) + let reclaimed = try #require(reclaimedClaim) + reclaimed.release() + let leftovers = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner_identity") + } + #expect(leftovers == 0) + } + + @Test("Malformed identity stamps are rejected on write and on read") + func malformedStamp() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: UUID().uuidString, bootSession: "not-a-boot")) + } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false) + let held = try #require(heldClaim) + defer { held.release() } + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + try queue.write { db in + try db.execute(sql: "UPDATE owner_identity SET bootSession = 'garbage'") + } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) + } + } + + @Test("This process stamps its claims with the boot session and its start time") + func currentIdentityIsStamped() { + let current = StoreProcessOwnership.Identity.current + #expect(current.hasValidStamp) + #expect(UUID(uuidString: current.bootSession) != nil) + #expect(current.startTime > 0) + #expect(StoreProcessOwnership.processStartTime(getpid()) == current.startTime) + } +} diff --git a/docs/STORE_RECOVERY.md b/docs/STORE_RECOVERY.md new file mode 100644 index 00000000..de25c7e8 --- /dev/null +++ b/docs/STORE_RECOVERY.md @@ -0,0 +1,95 @@ +# Store recovery and process ownership + +The complete database, WAL/SHM, interrupted encrypted export, blobs and thumbnails +are preserved as one journaled, resumable unreadable generation. Fresh-key recovery +never reuses an old sealed-blob namespace. + +## Suspension-safe ownership + +The stable `.store-ownership.sqlite` sidecar contains only process IDs and random +process/operation UUIDs, never clip content, blob hashes, paths from clips, or keys. +It is outside the preserved blob/database generation. Its versioned metadata schema +uses WAL. Short synchronous writer transactions atomically acquire, downgrade, or +release logical claims; no new application-owned file lock or coordinator +transaction is held during pool lifetime, filesystem moves, blob work, or `await`. + +A shared generation registration is retained by the store and each actual SQLite +writer/reader/snapshot connection. Configuration preparation captures it weakly +and fails closed if it is gone. A private per-connection SQL function owns one ARC +reference through SQLite's `sqlite3_create_function_v2` destructor; its scalar +callback returns NULL and no application query or persisted schema uses it. The +destructor drops only that connection's reference after SQLite closes its pager, +including delayed `close_v2` zombie completion. Configuration/watchdog objects may +survive without over-pinning a closed connection. Registration failure also invokes +the destructor, so there is no second release on the failure path. See the +[SQLite function lifetime contract](https://www.sqlite.org/c3ref/create_function.html) +and [close contract](https://www.sqlite.org/c3ref/close.html). SQLCipher builds use +SQLCipher C symbols, not system SQLite functions on SQLCipher pointers. Exclusive recovery conflicts with all generation and blob +claims. Registration precedes inspecting the journal/header. Pending recovery or +plaintext/absent database conversion requires exclusive ownership; the journal is +rechecked after acquiring it. The claim remains exclusive through the namespace +change and pool initialization, then downgrades to a shared registration. A failed +or interrupted move keeps its journal; the next exclusive owner resumes it before +any new pool can open that generation. + +On iOS, `DatabaseSuspension.suspend()` fences and finishes coordinator metadata +transactions before posting GRDB suspension, and rejects new coordinators opened +after that boundary. Coordinator connections also observe GRDB suspension. Logical +claims remain committed while suspended, without keeping a kernel lock. Release +uses the exact operation and process UUID; a failed/suspended release is retained +for a bounded resume cleanup pass or the next operation at that directory. +Coordinator connection close/checkpoint is also fenced; suspended/busy releases +retain their queues until safe cleanup, rather than relying on deinitialization. Only +known-finished operations enter this retry set. Cancellation never releases an +operation before its protected transaction/work has finished. + +Every claim is stamped with the kernel boot session (`kern.bootsessionuuid`) and, +where `sysctl(KERN_PROC_PID)` reports it, the claimant's process start time. A +claim is reclaimed only on positive evidence: + +- its boot session differs from the current one (claims never survive a reboot, + and PIDs restart at boot, so this covers the most common reuse case); +- `kill(pid, 0)` reports `ESRCH`, which Apple's [iOS kill(2) manual](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kill.2.html) + defines as absence of the process; or +- the PID can be signalled (`kill` succeeds) and its current start time differs + from the recorded one, i.e. the PID now belongs to another process. + +Unknown stamps (an unavailable sysctl, or a claim migrated from the version-1 +ledger), permission/sandbox failures, unknown errors and processes whose start +time cannot be read remain fail-closed; malformed stamps fail the ledger closed. +UUID mismatch, elapsed time, missing heartbeats and suspension are never evidence +of death. Recovery returns busy immediately; async blob ownership has a bounded +wait. Within one boot, a claim whose PID was reused by a process this one may not +signal (another user's, or another sandboxed app's on iOS) still withholds +availability until that PID is provably absent. No destructive +timeout/forced-reclamation path is provided. The user-facing caller must report +busy/error, not silently reset. + +The ledger is versioned with `user_version`. Version 2 adds the +`owner_identity` table beside `owner`; a version-1 ledger is migrated in place by +creating that table, and its existing claims keep their PID-only judgement. A +missing table, an unknown version or an altered schema still fails closed and is +never recreated. + +## Compatibility and qualification boundary + +The main schema and sealed data format are unchanged. Older binaries ignore the +sidecar and do not participate in this protocol. Absence of registrations does not +prove an old pool is closed. Ownership safety therefore requires a quiescent, +coordinated upgrade/restart of every participant (app, extensions, CLI, MCP and +maintenance tools). Mixed-version writers or recovery are not qualified; rollout +must stop older participants before enabling the new recovery/cleanup paths. The +optional raw-key rekey remains separately gated by its existing opt-in and +quiescent/device rollout requirements; this repair does not qualify that migration. + +Deterministic metadata/state tests prove atomic claims, scope conflicts, exact-token +release, exited/unverifiable/reused identity policy, deferred cleanup and WAL idle +writes. They do not prove iOS sandbox liveness behavior or physical-device jetsam +avoidance. iOS app/keyboard/share/widget suspension, expiration, restart and reboot +stress across real App Group processes remain release gates. See Apple's +[TN2408](https://developer.apple.com/library/archive/technotes/tn2408/_index.html) +and [DTS suspension discussion](https://developer.apple.com/forums/thread/655225). + +The global suspension-notification seam should run in an isolated/serial iOS test +invocation; suite-local serialization does not isolate it from other test suites. +The deterministic ownership tests use independent injected lifecycle objects.