TM-SGNL-iOS/SignalServiceKit/Util/SqliteUtil.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

237 lines
9.1 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import GRDB
/// A bucket for SQLite utilities.
public enum SqliteUtil {
/// Determine whether a table, column, or view name *could* lead to SQL injection.
///
/// In some cases, you'd like to write something like this:
///
/// // This causes an error:
/// let sql = "SELECT * FROM ?"
/// try Row.fetchAll(db, sql: sql, arguments: [myTableName])
///
/// Unfortunately, GRDB (perhaps because of SQLite) doesn't allow this kind of thing. That means
/// we have to use string interpolation, which can be dangerous due to SQL injection. This helps
/// keep that safe.
///
/// Instead, you'd write something like this:
///
/// owsAssert(SqliteUtil.isSafe(myTableName))
/// let sql = "SELECT * FROM \(myTableName)"
/// try Row.fetchAll(db, sql: sql)
///
/// This is unlikely to happen for our app, and should always return `true`.
///
/// This check may return false negatives. For example, SQLite supports empty table names which
/// this function would mark unsafe.
///
/// - Parameter sqlName: The table, column, or view name to be checked.
/// - Returns: Whether the name is safe to use in SQL string interpolation.
public static func isSafe(sqlName: String) -> Bool {
!sqlName.isEmpty &&
sqlName.utf8.count < 1000 &&
!sqlName.lowercased().starts(with: "sqlite") &&
sqlName.range(of: "^[a-zA-Z][a-zA-Z0-9_]*$", options: .regularExpression) != nil
}
/// Enable or disable `F_BARRIERFSYNC`.
///
/// Under the hood, this calls [`PRAGMA fullfsync`][0]. You'd think that this would affect
/// `FULLFSYNC` instead, but we modify SQLCipher to replace `FULLFSYNC` with `FULLFSYNC` with
/// `BARRIERFSYNC`. This helps us balance reliability and performance.
///
/// [0]: https://www.sqlite.org/pragma.html#pragma_fullfsync
public static func setBarrierFsync(db: Database, enabled: Bool) throws {
try db.execute(sql: "PRAGMA fullfsync = \(enabled ? "ON" : "OFF")")
}
public enum IntegrityCheckResult {
case ok
case notOk
public static func && (
lhs: IntegrityCheckResult,
rhs: IntegrityCheckResult
) -> IntegrityCheckResult {
switch (lhs, rhs) {
case (.ok, .ok): return .ok
default: return .notOk
}
}
}
/// Get the result of [`PRAGMA cipher_provider`][0].
///
/// [0]: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_provider
public static func cipherProvider(db: Database) -> String {
return (try? String.fetchOne(db, sql: "PRAGMA cipher_provider")) ?? ""
}
/// Run [`PRAGMA cipher_integrity_check`][0], log the results, and report whether the check
/// succeeded.
///
/// [0]: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_integrity_check
public static func cipherIntegrityCheck(db: Database) -> IntegrityCheckResult {
var hasAnyOutput = false
do {
let cursor = try String.fetchCursor(db, sql: "PRAGMA cipher_integrity_check")
while let line = try cursor.next() {
let strippedLine = line.stripped
Logger.info(strippedLine)
hasAnyOutput = hasAnyOutput || !strippedLine.isEmpty
}
} catch {
Logger.error("PRAGMA cipher_integrity_check failed to run")
return .notOk
}
if hasAnyOutput {
Logger.error("PRAGMA cipher_integrity_check failed")
return .notOk
} else {
return .ok
}
}
/// Run [`PRAGMA quick_check`][0] and report whether the check succeeded.
///
/// We don't log much because that *could* log sensitive user data with really bad corruption.
///
/// [0]: https://www.sqlite.org/pragma.html#pragma_quick_check
public static func quickCheck(db: Database) -> IntegrityCheckResult {
let firstQuickCheckLine: String?
do {
firstQuickCheckLine = try String.fetchOne(db, sql: "PRAGMA quick_check")
} catch {
Logger.error("PRAGMA quick_check failed to run")
return .notOk
}
if firstQuickCheckLine?.starts(with: "ok") == true {
Logger.info("PRAGMA quick_check: ok")
return .ok
} else {
#if DEBUG
Logger.error("PRAGMA quick_check failed (\(firstQuickCheckLine ?? "<nil>"))")
#else
Logger.error("PRAGMA quick_check failed (failure redacted)")
#endif
return .notOk
}
}
/// Run [`REINDEX`][0].
///
/// [0]: https://sqlite.org/lang_reindex.html
public static func reindex(db: Database) throws {
try db.execute(sql: "REINDEX")
}
/// A bucket for FTS5 utilities.
public enum Fts5 {
public enum IntegrityCheckResult {
case ok
case corrupted
}
/// Run an [integrity-check command] on an FTS5 table.
///
/// - Parameter db: A database connection.
/// - Parameter ftsTableName: The virtual FTS5 table to use. This table name must be "safe"
/// according to ``Sqlite.isSafe``. If it's not, a fatal error will be thrown.
/// - Parameter rank: The `rank` parameter to use. See the SQLite docs for more information.
/// - Returns: An integrity check result.
///
/// [integrity-check command]: https://www.sqlite.org/fts5.html#the_integrity_check_command
public static func integrityCheck(
db: Database,
ftsTableName: String,
compareToExternalContentTable: Bool
) throws -> IntegrityCheckResult {
owsPrecondition(SqliteUtil.isSafe(sqlName: ftsTableName))
let sql: String
if compareToExternalContentTable {
sql = "INSERT INTO \(ftsTableName) (\(ftsTableName), rank) VALUES ('integrity-check', 1)"
} else {
sql = "INSERT INTO \(ftsTableName) (\(ftsTableName)) VALUES ('integrity-check')"
}
do {
try db.execute(sql: sql)
} catch {
if
let dbError = error as? DatabaseError,
dbError.extendedResultCode == .SQLITE_CORRUPT_VTAB
{
return .corrupted
} else {
throw error
}
}
return .ok
}
/// Run a [rebuild command] on an FTS5 table.
///
/// - Parameter db: A database connection.
/// - Parameter ftsTableName: The virtual FTS5 table to use. This table name must be "safe"
/// according to ``Sqlite.isSafe``. If it's not, a fatal error will be thrown.
///
/// [rebuild command]: https://www.sqlite.org/fts5.html#the_rebuild_command
public static func rebuild(db: Database, ftsTableName: String) throws {
owsPrecondition(SqliteUtil.isSafe(sqlName: ftsTableName))
try db.execute(
sql: "INSERT INTO \(ftsTableName) (\(ftsTableName)) VALUES ('rebuild')"
)
}
public enum MergeResult {
case workWasPerformed
case noop
}
/// Run a [merge command] on an FTS5 table.
///
/// - Parameter db: A database connection.
/// - Parameter ftsTableName: The virtual FTS5 table to use. This table name must be "safe"
/// according to ``Sqlite.isSafe``. If it's not, a fatal error will be thrown.
/// - Parameter numberOfPages: The number of pages to merge in a single batch.
/// - Parameter isFirstBatch: If true, some extra pre-work will be performed.
/// - Returns: A merge result, indicating whether any work was performed.
///
/// [merge command]: https://www.sqlite.org/fts5.html#the_merge_command
public static func merge(
db: Database,
ftsTableName: String,
numberOfPages: Int,
isFirstBatch: Bool
) throws -> MergeResult {
let totalChangesBefore = db.totalChangesCount
owsPrecondition(SqliteUtil.isSafe(sqlName: ftsTableName))
try db.execute(
sql: "INSERT INTO \(ftsTableName) (\(ftsTableName), rank) VALUES ('merge', ?)",
arguments: [isFirstBatch ? -numberOfPages : numberOfPages]
)
// From the SQLite docs: "It is possible to tell whether or not the 'merge' command
// found any b-trees to merge together by checking the value returned by the
// sqlite3_total_changes() API before and after the command is executed. If the
// difference between the two values is 2 or greater, then work was performed.
// If the difference is less than 2, then the 'merge' command was a no-op."
let totalChangesAfter = db.totalChangesCount
let wasWorkPerformed = totalChangesAfter - totalChangesBefore >= 2
return wasWorkPerformed ? .workWasPerformed : .noop
}
}
}