// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // // stringlint:disable import UIKit import CryptoKit import Combine import GRDB #if DEBUG import Darwin #endif // MARK: - Singleton public extension Singleton { static let storage: SingletonConfig = Dependencies.create( identifier: "storage", createInstance: { dependencies in Storage(using: dependencies) } ) static let scheduler: SingletonConfig = Dependencies.create( identifier: "scheduler", createInstance: { _ in AsyncValueObservationScheduler.async(onQueue: .main) } ) } // MARK: - Log.Category public extension Log.Category { static let storage: Log.Category = .create("Storage", defaultLevel: .info) } // MARK: - KeychainStorage public extension KeychainStorage.DataKey { static let dbCipherKeySpec: Self = "GRDBDatabaseCipherKeySpec" } // MARK: - Storage open class Storage { public struct CurrentlyRunningMigration: ThreadSafeType { public let identifier: TargetMigrations.Identifier public let migration: Migration.Type } public static let queuePrefix: String = "SessionDatabase" public static let dbFileName: String = "Session.sqlite" private static let SQLCipherKeySpecLength: Int = 48 /// If a transaction takes longer than this duration a warning will be logged but the transaction will continue to run private static let slowTransactionThreshold: TimeInterval = 3 /// When attempting to do a write the transaction will wait this long to acquite a lock before failing private static let writeTransactionStartTimeout: TimeInterval = 5 /// If a transaction takes longer than this duration then we should fail the transaction rather than keep hanging private static let transactionDeadlockTimeoutSeconds: Int = 5 private static var sharedDatabaseDirectoryPath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/database" } private static var databasePath: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)" } private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" } private static var databasePathWal: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-wal" } private let dependencies: Dependencies fileprivate var dbWriter: DatabaseWriter? internal var testDbWriter: DatabaseWriter? { dbWriter } // MARK: - Migration Variables @ThreadSafeObject private var migrationProgressUpdater: ((String, CGFloat) -> ())? @ThreadSafe private var internalCurrentlyRunningMigration: CurrentlyRunningMigration? = nil @ThreadSafe private var migrationsCompleted: Bool = false public var hasCompletedMigrations: Bool { migrationsCompleted } public var currentlyRunningMigration: CurrentlyRunningMigration? { internalCurrentlyRunningMigration } // MARK: - Database State Variables private var startupError: Error? public private(set) var isValid: Bool = false public private(set) var isSuspended: Bool = false public var isDatabasePasswordAccessible: Bool { ((try? getDatabaseCipherKeySpec()) != nil) } /// This property gets set the first time we successfully read from the database public private(set) var hasSuccessfullyRead: Bool = false /// This property gets set the first time we successfully write to the database public private(set) var hasSuccessfullyWritten: Bool = false // MARK: - Initialization public init(customWriter: DatabaseWriter? = nil, using dependencies: Dependencies) { self.dependencies = dependencies configureDatabase(customWriter: customWriter) } public init( testAccessTo databasePath: String, encryptedKeyPath: String, encryptedKeyPassword: String, using dependencies: Dependencies ) throws { self.dependencies = dependencies try testAccess( databasePath: databasePath, encryptedKeyPath: encryptedKeyPath, encryptedKeyPassword: encryptedKeyPassword ) } private func configureDatabase(customWriter: DatabaseWriter? = nil) { // Create the database directory if needed and ensure it's protection level is set before attempting to // create the database KeySpec or the database itself try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: Storage.sharedDatabaseDirectoryPath) try? dependencies[singleton: .fileManager].protectFileOrFolder(at: Storage.sharedDatabaseDirectoryPath) // If a custom writer was provided then use that (for unit testing) guard customWriter == nil else { dbWriter = customWriter isValid = true return } /// Generate the database KeySpec if needed (this MUST be done before we try to access the database as a different thread /// might attempt to access the database before the key is successfully created) /// /// We reset the bytes immediately after generation to ensure the database key doesn't hang around in memory unintentionally /// /// **Note:** If we fail to get/generate the keySpec then don't bother continuing to setup the Database as it'll just be invalid, /// in this case the App/Extensions will have logic that checks the `isValid` flag of the database do { var tmpKeySpec: Data = try getOrGenerateDatabaseKeySpec() tmpKeySpec.resetBytes(in: 0.. Set { let migrator: DatabaseMigrator = DatabaseMigrator() return (try? migrator.appliedIdentifiers(db)) .defaulting(to: []) } public static func sortedMigrationInfo(migrationTargets: [MigratableTarget.Type]) -> [KeyedMigration] { typealias MigrationInfo = (identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet) return migrationTargets .map { target -> TargetMigrations in target.migrations() } .sorted() .reduce(into: [[MigrationInfo]]()) { result, next in next.migrations.enumerated().forEach { index, migrationSet in if result.count <= index { result.append([]) } result[index] = (result[index] + [(next.identifier, migrationSet)]) } } .reduce(into: []) { result, next in next.forEach { identifier, migrations in result.append(contentsOf: migrations.map { (identifier.key(with: $0), identifier, $0) }) } } } public func perform( migrationTargets: [MigratableTarget.Type], async: Bool = true, onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, onComplete: @escaping (Result) -> () ) { perform( sortedMigrations: Storage.sortedMigrationInfo(migrationTargets: migrationTargets), async: async, onProgressUpdate: onProgressUpdate, onComplete: onComplete ) } internal func perform( sortedMigrations: [KeyedMigration], async: Bool, onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, onComplete: @escaping (Result) -> () ) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { let error: Error = (startupError ?? StorageError.startupFailed) Log.error(.storage, "Statup failed with error: \(error)") onComplete(.failure(error)) return } // Setup and run any required migrations var migrator: DatabaseMigrator = DatabaseMigrator() sortedMigrations.forEach { _, identifier, migration in migrator.registerMigration( self, targetIdentifier: identifier, migration: migration, using: dependencies ) } // Determine which migrations need to be performed and gather the relevant settings needed to // inform the app of progress/states let completedMigrations: [String] = (try? dbWriter.read { db in try migrator.completedMigrations(db) }) .defaulting(to: []) let unperformedMigrations: [KeyedMigration] = sortedMigrations .reduce(into: []) { result, next in guard !completedMigrations.contains(next.key) else { return } result.append(next) } let migrationToDurationMap: [String: TimeInterval] = unperformedMigrations .reduce(into: [:]) { result, next in result[next.key] = next.migration.minExpectedRunDuration } let unperformedMigrationDurations: [TimeInterval] = unperformedMigrations .map { _, _, migration in migration.minExpectedRunDuration } let totalMinExpectedDuration: TimeInterval = migrationToDurationMap.values.reduce(0, +) self._migrationProgressUpdater.set(to: { targetKey, progress in guard let migrationIndex: Int = unperformedMigrations.firstIndex(where: { key, _, _ in key == targetKey }) else { return } let completedExpectedDuration: TimeInterval = ( (migrationIndex > 0 ? unperformedMigrationDurations[0..) -> () = { [weak self, migrator, dbWriter] result in // Make sure to transition the progress updater to 100% for the final migration (just // in case the migration itself didn't update to 100% itself) if let lastMigrationKey: String = unperformedMigrations.last?.key { self?.migrationProgressUpdater?(lastMigrationKey, 1) } self?.migrationsCompleted = true self?._migrationProgressUpdater.set(to: nil) // Don't log anything in the case of a 'success' or if the database is suspended (the // latter will happen if the user happens to return to the background too quickly on // launch so is unnecessarily alarming, it also gets caught and logged separately by // the 'write' functions anyway) switch result { case .success: break case .failure(DatabaseError.SQLITE_ABORT): break case .failure(let error): let completedMigrations: [String] = (try? dbWriter .read { db in try migrator.completedMigrations(db) }) .defaulting(to: []) let failedMigrationName: String = migrator.migrations .filter { !completedMigrations.contains($0) } .first .defaulting(to: "Unknown") Log.critical(.migration, "Migration '\(failedMigrationName)' failed with error: \(error)") } onComplete(result) } // if there aren't any migrations to run then just complete immediately (this way the migrator // doesn't try to execute on the DBWrite thread so returning from the background can't get blocked // due to some weird endless process running) guard !unperformedMigrations.isEmpty else { migrationCompleted(.success(())) return } // If we have an unperformed migration then trigger the progress updater immediately if let firstMigrationKey: String = unperformedMigrations.first?.key { self.migrationProgressUpdater?(firstMigrationKey, 0) } // Note: The non-async migration should only be used for unit tests guard async else { return migrationCompleted(Result(catching: { try migrator.migrate(dbWriter) })) } migrator.asyncMigrate(dbWriter) { [dependencies] result in let finalResult: Result = { switch result { case .failure(let error): return .failure(error) case .success: return .success(()) } }() // Note: We need to dispatch this to the next run toop to prevent blocking if the callback // performs subsequent database operations DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { migrationCompleted(finalResult) } } } public func willStartMigration( _ db: Database, _ migration: Migration.Type, _ identifier: TargetMigrations.Identifier ) { internalCurrentlyRunningMigration = CurrentlyRunningMigration( identifier: identifier, migration: migration ) } public func didCompleteMigration() { internalCurrentlyRunningMigration = nil } public static func update( progress: CGFloat, for migration: Migration.Type, in target: TargetMigrations.Identifier, using dependencies: Dependencies ) { // In test builds ignore any migration progress updates (we run in a custom database writer anyway) guard !SNUtilitiesKit.isRunningTests else { return } dependencies[singleton: .storage].migrationProgressUpdater?(target.key(with: migration), progress) } // MARK: - Security private func getDatabaseCipherKeySpec() throws -> Data { try dependencies[singleton: .keychain].migrateLegacyKeyIfNeeded( legacyKey: "GRDBDatabaseCipherKeySpec", legacyService: "TSKeyChainService", toKey: .dbCipherKeySpec ) return try dependencies[singleton: .keychain].data(forKey: .dbCipherKeySpec) } private func getOrGenerateDatabaseKeySpec() throws -> Data { do { var keySpec: Data = try getDatabaseCipherKeySpec() defer { keySpec.resetBytes(in: 0..(_ error: Error, isWrite: Bool) -> T? { logIfNeeded(error, isWrite: isWrite) return nil } static func logIfNeeded(_ error: Error, isWrite: Bool) -> AnyPublisher { logIfNeeded(error, isWrite: isWrite) return Fail(error: error).eraseToAnyPublisher() } } // MARK: - Operations private static func track( _ db: Database, _ info: CallInfo, _ operation: @escaping (Database) throws -> T ) throws -> T { guard info.storage?.isSuspended == false else { throw StorageError.databaseSuspended } let timer: TransactionTimer = TransactionTimer.start( duration: Storage.slowTransactionThreshold, info: info ) defer { timer.stop() } // Get the result let result: T = try operation(db) // Update the state flags switch info.isWrite { case true: info.storage?.hasSuccessfullyWritten = true case false: info.storage?.hasSuccessfullyRead = true } return result } /// This function manually performs `read`/`write` operations in either a synchronous or asyncronous way using a semaphore to /// block the syncrhonous version because `GRDB` has an internal assertion when using it's built-in synchronous `read`/`write` /// functions to prevent reentrancy which is unsupported /// /// Unfortunately this results in the code getting messy when trying to chain multiple database transactions (even /// when using `db.afterNextTransaction`) which is somewhat unintuitive /// /// The `async` variants don't need to worry about this reentrancy issue so instead we route we use those for all operations instead /// and just block the thread when we want to perform a synchronous operation /// /// **Note:** When running a synchronous operation the result will be returned and `asyncCompletion` will not be called, and /// vice-versa for an asynchronous operation @discardableResult private static func performOperation( _ info: CallInfo, _ dependencies: Dependencies, _ operation: @escaping (Database) throws -> T, _ asyncCompletion: ((Result) -> Void)? = nil ) -> Result { // A serial queue for synchronizing completion updates. let syncQueue = DispatchQueue(label: "com.session.performOperation.syncQueue") var queryDb: Database? var didTimeout: Bool = false var operationResult: Result? let semaphore: DispatchSemaphore? = (info.isAsync ? nil : DispatchSemaphore(value: 0)) let logErrorIfNeeded: (Result) -> Result = { result in switch result { case .success: break case .failure(let error): StorageState.logIfNeeded(error, isWrite: info.isWrite) } return result } func completeOperation(with result: Result) { syncQueue.sync { guard !didTimeout && operationResult == nil else { return } operationResult = result semaphore?.signal() // For async operations, log and invoke the completion closure. if info.isAsync { asyncCompletion?(logErrorIfNeeded(result)) } } } /// Perform the actual operation switch (StorageState(info.storage), info.isWrite) { case (.invalid(let error), _): completeOperation(with: .failure(error)) case (.valid(let dbWriter), true): dbWriter.asyncWrite( { db in syncQueue.sync { queryDb = db } if dependencies[feature: .forceSlowDatabaseQueries] { Thread.sleep(forTimeInterval: 1) } return try Storage.track(db, info, operation) }, completion: { _, dbResult in completeOperation(with: dbResult) } ) case (.valid(let dbWriter), false): dbWriter.asyncRead { dbResult in do { switch dbResult { case .failure(let error): throw error case .success(let db): syncQueue.sync { queryDb = db } if dependencies[feature: .forceSlowDatabaseQueries] { Thread.sleep(forTimeInterval: 1) } completeOperation(with: .success(try Storage.track(db, info, operation))) } } catch { completeOperation(with: .failure(error)) } } } /// If this is a synchronous operation then `semaphore` will exist and will block here waiting on the signal from one of the /// above closures to be sent /// /// **Note:** Unfortunately this timeout can be really annoying when debugging because the semaphore timeout is based on /// system time which doesn't get paused when stopping on a breakpoint (which means if you break in the middle of a database /// query it's pretty much guaranteed to timeout) /// /// To try to avoid this we have the below code to try to replicate the behaviour of the proper semaphore timeout while the debugger /// is attached as this approach does seem to get paused (or at least only perform a single iteration per debugger step) if let semaphore: DispatchSemaphore = semaphore { var semaphoreResult: DispatchTimeoutResult #if DEBUG if isDebuggerAttached() { semaphoreResult = debugWait(semaphore: semaphore, info: info) } else { semaphoreResult = semaphore.wait(timeout: .now() + .seconds(Storage.transactionDeadlockTimeoutSeconds)) } #else semaphoreResult = semaphore.wait(timeout: .now() + .seconds(Storage.transactionDeadlockTimeoutSeconds)) #endif /// If the query timed out then we should interrupt the query (don't want the query thread to remain blocked when we've /// already handled it as a failure) if semaphoreResult == .timedOut { syncQueue.sync { didTimeout = true queryDb?.interrupt() } } /// Before returning we need to wait for any pending updates on `syncQueue` to be completed to ensure that objects /// don't get incorrectly released while they are still being used syncQueue.sync { } return logErrorIfNeeded(operationResult ?? .failure(StorageError.transactionDeadlockTimeout)) } /// For the `async` operation the returned value should be ignored so just return the `invalidQueryResult` error return .failure(StorageError.invalidQueryResult) } private func performPublisherOperation( _ fileName: String, _ functionName: String, _ lineNumber: Int, isWrite: Bool, _ operation: @escaping (Database) throws -> T ) -> AnyPublisher { switch StorageState(self) { case .invalid(let error): return StorageState.logIfNeeded(error, isWrite: false) case .valid: /// **Note:** GRDB does have `readPublisher`/`writePublisher` functions but it appears to asynchronously /// trigger both the `output` and `complete` closures at the same time which causes a lot of unexpected /// behaviours (this behaviour is apparently expected but still causes a number of odd behaviours in our code /// for more information see https://github.com/groue/GRDB.swift/issues/1334) /// /// Instead of this we are just using `Deferred { Future {} }` which is executed on the specified scheduled /// which behaves in a much more expected way than the GRDB `readPublisher`/`writePublisher` does let info: CallInfo = CallInfo(self, fileName, functionName, lineNumber, .syncWrite) return Deferred { [dependencies] in Future { resolver in resolver(Storage.performOperation(info, dependencies, operation)) } }.eraseToAnyPublisher() } } private static func debugWait(semaphore: DispatchSemaphore, info: CallInfo) -> DispatchTimeoutResult { let pollQueue: DispatchQueue = DispatchQueue(label: "com.session.debugWaitTimer.\(UUID().uuidString)") let standardPollInterval: DispatchTimeInterval = .milliseconds(100) var iterations: Int = 0 let maxIterations: Int = ((Storage.transactionDeadlockTimeoutSeconds * 1000) / standardPollInterval.milliseconds) let pollCompletionSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0) /// Stagger the size of the `pollIntervals` to avoid holding up the thread in case the query resolves very quickly (this /// means the timeout will occur ~500ms early but helps prevent false main thread lag appearing when debugging that wouldn't /// affect production) let pollIntervals: [DispatchTimeInterval] = [ .milliseconds(5), .milliseconds(5), .milliseconds(10), .milliseconds(10), .milliseconds(10), standardPollInterval ] func pollSemaphore() { iterations += 1 guard iterations < maxIterations && semaphore.wait(timeout: .now()) != .success else { pollCompletionSemaphore.signal() return } let nextInterval: DispatchTimeInterval = pollIntervals[min(iterations, pollIntervals.count - 1)] pollQueue.asyncAfter(deadline: .now() + nextInterval) { pollSemaphore() } } /// Poll the semaphore in a background queue pollQueue.asyncAfter(deadline: .now() + pollIntervals[0]) { pollSemaphore() } pollCompletionSemaphore.wait() // Wait indefinitely for the timer semaphore return (iterations >= 50 ? .timedOut : .success) } // MARK: - Functions @discardableResult public func write( fileName file: String = #file, functionName funcN: String = #function, lineNumber line: Int = #line, updates: @escaping (Database) throws -> T? ) -> T? { switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncWrite), dependencies, updates) { case .failure: return nil case .success(let result): return result } } open func writeAsync( fileName file: String = #file, functionName funcN: String = #function, lineNumber line: Int = #line, updates: @escaping (Database) throws -> T, completion: @escaping (Result) -> Void = { _ in } ) { Storage.performOperation(CallInfo(self, file, funcN, line, .asyncWrite), dependencies, updates, completion) } open func writePublisher( fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, updates: @escaping (Database) throws -> T ) -> AnyPublisher { return performPublisherOperation(fileName, functionName, lineNumber, isWrite: true, updates) } @discardableResult public func read( fileName file: String = #file, functionName funcN: String = #function, lineNumber line: Int = #line, _ value: @escaping (Database) throws -> T? ) -> T? { switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncRead), dependencies, value) { case .failure: return nil case .success(let result): return result } } open func readPublisher( fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, value: @escaping (Database) throws -> T ) -> AnyPublisher { return performPublisherOperation(fileName, functionName, lineNumber, isWrite: false, value) } /// Rever to the `ValueObservation.start` method for full documentation /// /// - parameter observation: The observation to start /// - parameter scheduler: A Scheduler. By default, fresh values are /// dispatched asynchronously on the main queue. /// - parameter onError: A closure that is provided eventual errors that /// happen during observation /// - parameter onChange: A closure that is provided fresh values /// - returns: a DatabaseCancellable public func start( _ observation: ValueObservation, scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main), onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void ) -> DatabaseCancellable { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { onError(StorageError.databaseInvalid) return AnyDatabaseCancellable(cancel: {}) } return observation.start( in: dbWriter, scheduling: scheduler, onError: onError, onChange: onChange ) } public func addObserver(_ observer: TransactionObserver?) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard let observer: TransactionObserver = observer else { return } // Note: This actually triggers a write to the database so can be blocked by other // writes, since it's usually called on the main thread when creating a view controller // this can result in the UI hanging - to avoid this we dispatch (and hope there isn't // negative impact) DispatchQueue.global(qos: .default).async { dbWriter.add(transactionObserver: observer) } } public func removeObserver(_ observer: TransactionObserver?) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } guard let observer: TransactionObserver = observer else { return } // Note: This actually triggers a write to the database so can be blocked by other // writes, since it's usually called on the main thread when creating a view controller // this can result in the UI hanging - to avoid this we dispatch (and hope there isn't // negative impact) DispatchQueue.global(qos: .default).async { dbWriter.remove(transactionObserver: observer) } } } // MARK: - Combine Extensions public extension ValueObservation { func publisher( in storage: Storage, scheduling scheduler: ValueObservationScheduler ) -> AnyPublisher where Reducer: ValueReducer { guard storage.isValid, let dbWriter: DatabaseWriter = storage.dbWriter else { return Fail(error: StorageError.databaseInvalid).eraseToAnyPublisher() } return self.publisher(in: dbWriter, scheduling: scheduler) .eraseToAnyPublisher() } } public extension Publisher where Failure == Error { func flatMapStorageWritePublisher(using dependencies: Dependencies, updates: @escaping (Database, Output) throws -> T) -> AnyPublisher { return self.flatMap { output -> AnyPublisher in dependencies[singleton: .storage].writePublisher(updates: { db in try updates(db, output) }) }.eraseToAnyPublisher() } func flatMapStorageReadPublisher(using dependencies: Dependencies, value: @escaping (Database, Output) throws -> T) -> AnyPublisher { return self.flatMap { output -> AnyPublisher in dependencies[singleton: .storage].readPublisher(value: { db in try value(db, output) }) }.eraseToAnyPublisher() } } // MARK: - CallInfo private extension Storage { class CallInfo { enum Behaviour { case syncRead case asyncRead case syncWrite case asyncWrite } weak var storage: Storage? let file: String let function: String let line: Int let behaviour: Behaviour var callInfo: String { let fileInfo: String = (file.components(separatedBy: "/").last.map { "\($0):\(line) - " } ?? "") return "\(fileInfo)\(function)" } var isWrite: Bool { switch behaviour { case .syncWrite, .asyncWrite: return true case .syncRead, .asyncRead: return false } } var isAsync: Bool { switch behaviour { case .asyncRead, .asyncWrite: return true case .syncRead, .syncWrite: return false } } init( _ storage: Storage?, _ file: String, _ function: String, _ line: Int, _ behaviour: Behaviour ) { self.storage = storage self.file = file self.function = function self.line = line self.behaviour = behaviour } } } // MARK: - TransactionTimer private extension Storage { private static let timerQueue = DispatchQueue(label: "\(Storage.queuePrefix)-.transactionTimer", qos: .background) class TransactionTimer { private let info: Storage.CallInfo private let start: CFTimeInterval = CACurrentMediaTime() private var timer: DispatchSourceTimer? = DispatchSource.makeTimerSource(queue: Storage.timerQueue) private var wasSlowTransaction: Bool = false private init(info: Storage.CallInfo) { self.info = info } static func start(duration: TimeInterval, info: Storage.CallInfo) -> TransactionTimer { let result: TransactionTimer = TransactionTimer(info: info) result.timer?.schedule(deadline: .now() + .seconds(Int(duration)), repeating: .infinity) // Infinity to fire once result.timer?.setEventHandler { [weak result] in result?.timer?.cancel() result?.timer = nil let action: String = (info.isWrite ? "write" : "read") Log.warn("[Storage] Slow \(action) taking longer than \(Storage.slowTransactionThreshold, format: ".2", omitZeroDecimal: true)s - [ \(info.callInfo) ]") result?.wasSlowTransaction = true } result.timer?.resume() return result } func stop() { timer?.cancel() timer = nil guard wasSlowTransaction else { return } let end: CFTimeInterval = CACurrentMediaTime() let action: String = (info.isWrite ? "write" : "read") Log.warn("[Storage] Slow \(action) completed after \(end - start, format: ".2", omitZeroDecimal: true)s - [ \(info.callInfo) ]") } } } // MARK: - Debug Convenience public extension Storage { static let encKeyFilename: String = "key.enc" func testAccess( databasePath: String, encryptedKeyPath: String, encryptedKeyPassword: String ) throws { /// First we need to ensure we can decrypt the encrypted key file do { var tmpKeySpec: Data = try decryptSecureExportedKey( path: encryptedKeyPath, password: encryptedKeyPassword ) tmpKeySpec.resetBytes(in: 0.. String { var keySpec: Data = try getOrGenerateDatabaseKeySpec() defer { keySpec.resetBytes(in: 0.. Data { let encKeyBase64: String = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8) guard var passwordData: Data = password.data(using: .utf8), var encKeyData: Data = Data(base64Encoded: encKeyBase64) else { throw StorageError.generic } defer { // Reset content immediately after use passwordData.resetBytes(in: 0.. Bool { var info = kinfo_proc() var size = MemoryLayout.stride var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] let sysctlResult = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) guard sysctlResult == 0 else { return false } return (info.kp_proc.p_flag & P_TRACED) != 0 } #endif