From 1c47f7fd328d94dfe8e2a4706f30dd71e2572ded Mon Sep 17 00:00:00 2001 From: Andrew Plaza Date: Sat, 22 Aug 2026 17:01:24 -0400 Subject: [PATCH] feat: expose dbIntegrityCheck and checkDatabaseIntegrity across all SDKs One combined surface change per platform, mirroring the node bindings: - bindings/wasm + browser-sdk (worker-RPC client method; OPFS-worker free fn) - bindings/mobile (uniffi enum/record, spawn_blocking around the sync core) - sdks/android (Kotlin wrappers, companion static) - sdks/ios (Swift wrappers; compile gated by CI) Co-Authored-By: Claude Fable 5 --- bindings/mobile/src/mls.rs | 84 ++++++++++++++++++ bindings/mobile/src/mls/tests/integrity.rs | 86 ++++++++++++++++++ bindings/mobile/src/mls/tests/mod.rs | 1 + bindings/wasm/src/integrity.rs | 87 +++++++++++++++++++ bindings/wasm/src/lib.rs | 1 + .../org/xmtp/android/library/ClientTest.kt | 49 +++++++++++ .../java/org/xmtp/android/library/Client.kt | 28 ++++++ .../library/libxmtp/IntegrityCheckLevel.kt | 33 +++++++ .../library/libxmtp/IntegrityCheckOutcome.kt | 22 +++++ sdks/ios/Sources/XMTPiOS/Client.swift | 33 +++++++ sdks/ios/Sources/XMTPiOS/IntegrityCheck.swift | 40 +++++++++ sdks/ios/Tests/XMTPTests/ClientTests.swift | 28 ++++++ sdks/js/browser-sdk/src/Client.ts | 14 +++ sdks/js/browser-sdk/src/Opfs.ts | 12 +++ sdks/js/browser-sdk/src/WorkerClient.ts | 8 ++ sdks/js/browser-sdk/src/index.ts | 2 + .../browser-sdk/src/types/actions/client.ts | 10 +++ sdks/js/browser-sdk/src/types/actions/opfs.ts | 14 +++ sdks/js/browser-sdk/src/workers/client.ts | 5 ++ sdks/js/browser-sdk/src/workers/opfs.ts | 6 ++ sdks/js/browser-sdk/test/Client.test.ts | 15 +++- sdks/js/browser-sdk/test/Opfs.test.ts | 22 +++++ 22 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 bindings/mobile/src/mls/tests/integrity.rs create mode 100644 bindings/wasm/src/integrity.rs create mode 100644 sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckLevel.kt create mode 100644 sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckOutcome.kt create mode 100644 sdks/ios/Sources/XMTPiOS/IntegrityCheck.swift diff --git a/bindings/mobile/src/mls.rs b/bindings/mobile/src/mls.rs index a761f58912..af9141f7fc 100644 --- a/bindings/mobile/src/mls.rs +++ b/bindings/mobile/src/mls.rs @@ -530,6 +530,73 @@ pub async fn create_client( })) } +#[derive(uniffi::Enum, Debug, Clone, Copy)] +pub enum FfiIntegrityCheckLevel { + Quick, + Full, +} + +impl From for xmtp_db::prelude::IntegrityCheckLevel { + fn from(level: FfiIntegrityCheckLevel) -> Self { + match level { + FfiIntegrityCheckLevel::Quick => Self::Quick, + FfiIntegrityCheckLevel::Full => Self::Full, + } + } +} + +#[derive(uniffi::Record)] +pub struct FfiIntegrityCheckOutcome { + /// "ok" | "corrupt" | "unreadable" | "saltMissing" | "locked" | "failed" + pub outcome: String, + /// Row-level findings (corrupt) or the error/reason string (other + /// non-ok outcomes). Empty when ok. + pub findings: Vec, +} + +impl From for FfiIntegrityCheckOutcome { + fn from(r: xmtp_db::prelude::IntegrityCheckResult) -> Self { + use xmtp_db::prelude::IntegrityCheckResult::*; + let (outcome, findings) = match r { + Ok => ("ok", vec![]), + Corrupt { findings } => ("corrupt", findings), + Unreadable { reason } => ("unreadable", vec![reason]), + SaltMissing => ("saltMissing", vec![]), + Locked => ("locked", vec![]), + Failed { error } => ("failed", vec![error]), + }; + FfiIntegrityCheckOutcome { + outcome: outcome.into(), + findings, + } + } +} + +/// Read-only integrity check of a database file by path, without a client. +/// Runs on a blocking thread. For encrypted databases pass the same 32-byte +/// encryption key used to create the client. +#[uniffi::export(async_runtime = "tokio", default(encryption_key = None, level = None))] +pub async fn check_database_integrity( + db_path: String, + encryption_key: Option>, + level: Option, +) -> Result { + // Same 32-byte validation and error message as `create_client`'s key + // conversion. + let key = encryption_key + .map(|k| -> Result { + k.try_into() + .map_err(|_| "Malformed 32 byte encryption key".to_string()) + }) + .transpose()?; + let level = level.map(Into::into).unwrap_or_default(); + let result = tokio::task::spawn_blocking(move || { + xmtp_db::prelude::check_database_integrity(&db_path, key.as_ref(), level) + }) + .await?; + Ok(result.into()) +} + #[allow(unused)] #[uniffi::export(async_runtime = "tokio")] #[tracing::instrument(level = "debug", skip_all)] @@ -770,6 +837,23 @@ impl FfiXmtpClient { Ok(self.inner_client.reconnect_db()?) } + /// Read-only integrity check of this client's database. Uses a + /// blocking thread, never the async runtime. Persistent databases are + /// checked on a dedicated read-only connection without contending with + /// this client's DB operations; ephemeral in-memory databases use the + /// client's own connection. + #[tracing::instrument(skip_all)] + pub async fn db_integrity_check( + &self, + level: Option, + ) -> Result { + let client = self.inner_client.clone(); + let level = level.map(Into::into).unwrap_or_default(); + let result = + tokio::task::spawn_blocking(move || client.db_integrity_check(level)).await??; + Ok(result.into()) + } + /// Cleanly shut down this client: cancel in-flight workers and detached /// streams, then release the DB connection. Idempotent — a second call /// resolves to `Ok`. diff --git a/bindings/mobile/src/mls/tests/integrity.rs b/bindings/mobile/src/mls/tests/integrity.rs new file mode 100644 index 0000000000..ff6a2719a2 --- /dev/null +++ b/bindings/mobile/src/mls/tests/integrity.rs @@ -0,0 +1,86 @@ +//! Tests for the read-only database integrity check: the client method +//! (`FfiXmtpClient::db_integrity_check`) and the free function +//! (`check_database_integrity`). + +use crate::{DbOptions, check_database_integrity}; +use xmtp_db::EncryptedMessageStore; + +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_db_integrity_check_ok() { + let client = new_test_client().await; + let outcome = client.db_integrity_check(None).await.unwrap(); + assert_eq!(outcome.outcome, "ok"); + assert!(outcome.findings.is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_check_database_integrity_free_fn_ok() { + let ffi_inbox_owner = FfiWalletInboxOwner::new(); + let ident = ffi_inbox_owner.identifier(); + let nonce = 1; + let inbox_id = ident.inbox_id(nonce).unwrap(); + let db_path = tmp_path(); + let key: Vec = EncryptedMessageStore::<()>::generate_enc_key().into(); + + let client = create_client( + connect_to_backend_test().await, + DbOptions::new(Some(db_path.clone()), Some(key.clone()), None, None, None), + &inbox_id, + ident, + nonce, + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + register_client_with_wallet(&ffi_inbox_owner, &client).await; + // Release the DB connection before checking the file out from under a + // still-open client. + client.shutdown().await.unwrap(); + + let outcome = check_database_integrity(db_path, Some(key), None) + .await + .unwrap(); + assert_eq!(outcome.outcome, "ok"); + assert!(outcome.findings.is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_check_database_integrity_free_fn_wrong_key_is_unreadable() { + let ffi_inbox_owner = FfiWalletInboxOwner::new(); + let ident = ffi_inbox_owner.identifier(); + let nonce = 1; + let inbox_id = ident.inbox_id(nonce).unwrap(); + let db_path = tmp_path(); + let key: Vec = EncryptedMessageStore::<()>::generate_enc_key().into(); + + let client = create_client( + connect_to_backend_test().await, + DbOptions::new(Some(db_path.clone()), Some(key), None, None, None), + &inbox_id, + ident, + nonce, + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + register_client_with_wallet(&ffi_inbox_owner, &client).await; + client.shutdown().await.unwrap(); + + let wrong_key: Vec = EncryptedMessageStore::<()>::generate_enc_key().into(); + let outcome = check_database_integrity(db_path, Some(wrong_key), None) + .await + .unwrap(); + assert_eq!(outcome.outcome, "unreadable"); +} diff --git a/bindings/mobile/src/mls/tests/mod.rs b/bindings/mobile/src/mls/tests/mod.rs index 52434d0eb2..07754315b7 100644 --- a/bindings/mobile/src/mls/tests/mod.rs +++ b/bindings/mobile/src/mls/tests/mod.rs @@ -90,6 +90,7 @@ mod content_types; mod dms; mod group_management; mod identity; +mod integrity; mod lifecycle; mod networking; mod static_methods; diff --git a/bindings/wasm/src/integrity.rs b/bindings/wasm/src/integrity.rs new file mode 100644 index 0000000000..4fc08dc26f --- /dev/null +++ b/bindings/wasm/src/integrity.rs @@ -0,0 +1,87 @@ +use bindings_wasm_macros::wasm_bindgen_numbered_enum; +use serde::{Deserialize, Serialize}; +use tsify::Tsify; +use wasm_bindgen::prelude::*; +use xmtp_db::database::init_sqlite; + +use crate::ErrorWrapper; +use crate::client::Client; + +#[wasm_bindgen_numbered_enum] +#[derive(Default)] +pub enum IntegrityCheckLevel { + #[default] + Quick = 0, + Full = 1, +} + +impl From for xmtp_db::prelude::IntegrityCheckLevel { + fn from(level: IntegrityCheckLevel) -> Self { + match level { + IntegrityCheckLevel::Quick => Self::Quick, + IntegrityCheckLevel::Full => Self::Full, + } + } +} + +#[derive(Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct IntegrityCheckOutcome { + /// "ok" | "corrupt" | "unreadable" | "saltMissing" | "locked" | "failed" + pub outcome: String, + /// Row-level findings (corrupt) or the error/reason string (other + /// non-ok outcomes). Empty when ok. + pub findings: Vec, +} + +impl From for IntegrityCheckOutcome { + fn from(r: xmtp_db::prelude::IntegrityCheckResult) -> Self { + use xmtp_db::prelude::IntegrityCheckResult::*; + let (outcome, findings) = match r { + Ok => ("ok", vec![]), + Corrupt { findings } => ("corrupt", findings), + Unreadable { reason } => ("unreadable", vec![reason]), + SaltMissing => ("saltMissing", vec![]), + Locked => ("locked", vec![]), + Failed { error } => ("failed", vec![error]), + }; + IntegrityCheckOutcome { + outcome: outcome.into(), + findings, + } + } +} + +#[wasm_bindgen] +impl Client { + /// Read-only integrity check of this client's database. + /// + /// Wasm is single-threaded, so this runs the (sync) core check directly + /// on the current task instead of spawning a blocking thread. + #[wasm_bindgen(js_name = dbIntegrityCheck)] + pub async fn db_integrity_check( + &self, + level: Option, + ) -> Result { + let level = level.unwrap_or_default(); + let result = self + .inner_client() + .db_integrity_check(level.into()) + .map_err(ErrorWrapper::js)?; + Ok(result.into()) + } +} + +/// Read-only integrity check of a database file by path, without a client. +/// Wasm databases are unencrypted, so there is no encryption key parameter. +#[wasm_bindgen(js_name = checkDatabaseIntegrity)] +pub async fn check_database_integrity( + #[wasm_bindgen(js_name = dbPath)] db_path: String, + level: Option, +) -> Result { + init_sqlite().await; + let level = level.unwrap_or_default(); + let result = xmtp_db::prelude::check_database_integrity(&db_path, level.into()).await; + Ok(result.into()) +} diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index a2854b4afd..b6150f0f53 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -13,6 +13,7 @@ xmtp_common::if_wasm! { pub mod identity; pub mod inbox_id; pub mod inbox_state; + pub mod integrity; pub mod messages; pub mod opfs; pub mod permissions; diff --git a/sdks/android/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt b/sdks/android/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt index 970e6151da..a9a49a3eba 100644 --- a/sdks/android/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt +++ b/sdks/android/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt @@ -349,6 +349,55 @@ class ClientTest : BaseInstrumentedTest() { runBlocking { assertEquals(boClient.conversations.listGroups().size, 1) } } + @Test + fun testDbIntegrityCheck() { + val key = SecureRandom().generateSeed(32) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val fakeWallet = PrivateKeyBuilder() + val client = + runBlocking { + Client.create( + account = fakeWallet, + options = + ClientOptions( + ClientOptions.Api(XMTPEnvironment.LOCAL, false), + appContext = context, + dbEncryptionKey = key, + ), + ) + } + + val outcome = runBlocking { client.dbIntegrityCheck() } + assertEquals("ok", outcome.outcome) + assertEquals(emptyList(), outcome.findings) + } + + @Test + fun testCheckDatabaseIntegrityStatic() { + val key = SecureRandom().generateSeed(32) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val fakeWallet = PrivateKeyBuilder() + val client = + runBlocking { + Client.create( + account = fakeWallet, + options = + ClientOptions( + ClientOptions.Api(XMTPEnvironment.LOCAL, false), + appContext = context, + dbEncryptionKey = key, + ), + ) + } + + // Release the pooled connection so the free function's dedicated + // read-only connection isn't opened against a live client. + runBlocking { client.dropLocalDatabaseConnection() } + + val outcome = runBlocking { Client.checkDatabaseIntegrity(client.dbPath, key) } + assertEquals("ok", outcome.outcome) + } + @Test fun testCanGetAnInboxIdFromAddress() { val key = SecureRandom().generateSeed(32) diff --git a/sdks/android/library/src/main/java/org/xmtp/android/library/Client.kt b/sdks/android/library/src/main/java/org/xmtp/android/library/Client.kt index bd771a6675..bd10db4fa2 100644 --- a/sdks/android/library/src/main/java/org/xmtp/android/library/Client.kt +++ b/sdks/android/library/src/main/java/org/xmtp/android/library/Client.kt @@ -14,6 +14,8 @@ import org.xmtp.android.library.libxmtp.ArchiveOptions import org.xmtp.android.library.libxmtp.AvailableArchive import org.xmtp.android.library.libxmtp.IdentityKind import org.xmtp.android.library.libxmtp.InboxState +import org.xmtp.android.library.libxmtp.IntegrityCheckLevel +import org.xmtp.android.library.libxmtp.IntegrityCheckOutcome import org.xmtp.android.library.libxmtp.PublicIdentity import org.xmtp.android.library.libxmtp.SignatureRequest import org.xmtp.android.library.libxmtp.toFfi @@ -44,6 +46,7 @@ import uniffi.xmtpv3.inboxStateFromInboxIds import uniffi.xmtpv3.isConnected import uniffi.xmtpv3.revokeInstallations import java.io.File +import uniffi.xmtpv3.checkDatabaseIntegrity as ffiCheckDatabaseIntegrity import uniffi.xmtpv3.setNativeLogLevel as ffiSetNativeLogLevel typealias PreEventCallback = suspend () -> Unit @@ -257,6 +260,20 @@ class Client( return deletedCount } + /** + * Read-only integrity check of a database file by path, without a + * client. For encrypted databases pass the same 32-byte encryption + * key used to create the client. + */ + suspend fun checkDatabaseIntegrity( + dbPath: String, + encryptionKey: ByteArray? = null, + level: IntegrityCheckLevel = IntegrityCheckLevel.QUICK, + ): IntegrityCheckOutcome = + withContext(Dispatchers.IO) { + IntegrityCheckOutcome(ffiCheckDatabaseIntegrity(dbPath, encryptionKey, level.toFfi())) + } + suspend fun connectToApiBackend(api: ClientOptions.Api): XmtpApiClient { val cacheKey = api.toCacheKey() return cacheLock.withLock { @@ -815,6 +832,17 @@ class Client( ffiClient.dbReconnect() } + /** + * Read-only integrity check of this client's database, run off the main + * dispatcher. Persistent databases are checked on a dedicated read-only + * connection without blocking this client's DB operations; ephemeral + * in-memory databases use the client's own connection. + */ + suspend fun dbIntegrityCheck(level: IntegrityCheckLevel = IntegrityCheckLevel.QUICK): IntegrityCheckOutcome = + withContext(Dispatchers.IO) { + IntegrityCheckOutcome(ffiClient.dbIntegrityCheck(level.toFfi())) + } + /** * Bring the local store current with the network, then stop — for background * fetch and cold start, where holding a live stream is wasted because the diff --git a/sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckLevel.kt b/sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckLevel.kt new file mode 100644 index 0000000000..a8e625dfa8 --- /dev/null +++ b/sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckLevel.kt @@ -0,0 +1,33 @@ +package org.xmtp.android.library.libxmtp + +import uniffi.xmtpv3.FfiIntegrityCheckLevel + +/** + * How thorough a [org.xmtp.android.library.Client.dbIntegrityCheck] / + * [org.xmtp.android.library.Client.checkDatabaseIntegrity] run should be. + */ +enum class IntegrityCheckLevel { + /** + * Cheap structural checks without index-to-table cross-validation (and, on + * encrypted databases, without per-page HMAC validation), safe to run + * frequently. + */ + QUICK, + + /** + * Exhaustive check of every row and index entry. Slower; reserve for + * diagnostics. + */ + FULL, + + ; + + /** + * Converts this Kotlin enum to FFI IntegrityCheckLevel + */ + fun toFfi(): FfiIntegrityCheckLevel = + when (this) { + QUICK -> FfiIntegrityCheckLevel.QUICK + FULL -> FfiIntegrityCheckLevel.FULL + } +} diff --git a/sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckOutcome.kt b/sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckOutcome.kt new file mode 100644 index 0000000000..23944f5a40 --- /dev/null +++ b/sdks/android/library/src/main/java/org/xmtp/android/library/libxmtp/IntegrityCheckOutcome.kt @@ -0,0 +1,22 @@ +package org.xmtp.android.library.libxmtp + +import uniffi.xmtpv3.FfiIntegrityCheckOutcome + +/** + * Result of a [org.xmtp.android.library.Client.dbIntegrityCheck] / + * [org.xmtp.android.library.Client.checkDatabaseIntegrity] run. + * + * [outcome] is one of `"ok"`, `"corrupt"`, `"unreadable"`, `"saltMissing"`, + * `"locked"`, or `"failed"`. [findings] holds row-level findings for a + * `"corrupt"` outcome, or the error/reason string for other non-`"ok"` + * outcomes; it is empty when [outcome] is `"ok"`. + */ +data class IntegrityCheckOutcome( + val outcome: String, + val findings: List, +) { + internal constructor(ffi: FfiIntegrityCheckOutcome) : this( + outcome = ffi.outcome, + findings = ffi.findings, + ) +} diff --git a/sdks/ios/Sources/XMTPiOS/Client.swift b/sdks/ios/Sources/XMTPiOS/Client.swift index 1f115d225d..63e284b79d 100644 --- a/sdks/ios/Sources/XMTPiOS/Client.swift +++ b/sdks/ios/Sources/XMTPiOS/Client.swift @@ -801,6 +801,27 @@ public final class Client { ) } + /// Read-only integrity check of a database file by path, without a + /// client. For encrypted databases pass the same ``Data`` + /// ``encryptionKey`` used to create the client. + public static func checkDatabaseIntegrity( + dbPath: String, + encryptionKey: Data? = nil, + level: IntegrityCheckLevel = .quick + ) async throws -> IntegrityCheckOutcome { + let result: FfiIntegrityCheckOutcome + #if canImport(XMTPiOS) + result = try await XMTPiOS.checkDatabaseIntegrity( + dbPath: dbPath, encryptionKey: encryptionKey, level: level.toFfi() + ) + #else + result = try await XMTP.checkDatabaseIntegrity( + dbPath: dbPath, encryptionKey: encryptionKey, level: level.toFfi() + ) + #endif + return IntegrityCheckOutcome(result) + } + init( ffiClient: FfiXmtpClient, dbPath: String, installationID: String, inboxID: InboxId, environment: XMTPEnvironment, @@ -982,6 +1003,18 @@ public final class Client { ) } + /// Read-only integrity check of this client's local database. Persistent + /// databases are checked on a dedicated read-only connection without + /// blocking this client's DB operations; ephemeral in-memory databases + /// use the client's own connection. + public func dbIntegrityCheck(level: IntegrityCheckLevel = .quick) async throws + -> IntegrityCheckOutcome + { + try await IntegrityCheckOutcome( + ffiClient.dbIntegrityCheck(level: level.toFfi()) + ) + } + public func inboxIdFromIdentity(identity: PublicIdentity) async throws -> InboxId? { diff --git a/sdks/ios/Sources/XMTPiOS/IntegrityCheck.swift b/sdks/ios/Sources/XMTPiOS/IntegrityCheck.swift new file mode 100644 index 0000000000..37968be23c --- /dev/null +++ b/sdks/ios/Sources/XMTPiOS/IntegrityCheck.swift @@ -0,0 +1,40 @@ +import Foundation + +/// How thorough a ``Client/dbIntegrityCheck(level:)`` / +/// ``Client/checkDatabaseIntegrity(dbPath:encryptionKey:level:)`` run should +/// be. +public enum IntegrityCheckLevel: Sendable { + /// Cheap structural checks without index-to-table cross-validation (and, on + /// encrypted databases, without per-page HMAC validation), safe to run + /// frequently. + case quick + /// Exhaustive check of every row and index entry. Slower; reserve for + /// diagnostics. + case full + + func toFfi() -> FfiIntegrityCheckLevel { + switch self { + case .quick: + .quick + case .full: + .full + } + } +} + +/// Result of a ``Client/dbIntegrityCheck(level:)`` / +/// ``Client/checkDatabaseIntegrity(dbPath:encryptionKey:level:)`` run. +/// +/// ``outcome`` is one of `"ok"`, `"corrupt"`, `"unreadable"`, +/// `"saltMissing"`, `"locked"`, or `"failed"`. ``findings`` holds row-level +/// findings for a `"corrupt"` outcome, or the error/reason string for other +/// non-`"ok"` outcomes; it is empty when ``outcome`` is `"ok"`. +public struct IntegrityCheckOutcome: Sendable { + public let outcome: String + public let findings: [String] + + init(_ ffi: FfiIntegrityCheckOutcome) { + outcome = ffi.outcome + findings = ffi.findings + } +} diff --git a/sdks/ios/Tests/XMTPTests/ClientTests.swift b/sdks/ios/Tests/XMTPTests/ClientTests.swift index 96e9ead850..c5121abc65 100644 --- a/sdks/ios/Tests/XMTPTests/ClientTests.swift +++ b/sdks/ios/Tests/XMTPTests/ClientTests.swift @@ -173,6 +173,34 @@ class ClientTests: XCTestCase { try boClient.deleteLocalDatabase() } + func testDbIntegrityCheck() async throws { + let key = try Crypto.secureRandomBytes(count: 32) + let bo = try PrivateKey.generate() + let boClient = try await Client.create( + account: bo, + options: .init( + api: .init(env: .local, isSecure: XMTPEnvironment.local.isSecure), + dbEncryptionKey: key + ) + ) + + let outcome = try await boClient.dbIntegrityCheck() + XCTAssertEqual(outcome.outcome, "ok") + XCTAssertEqual(outcome.findings, []) + + // Release the pooled connection so the static function's dedicated + // read-only connection isn't opened against a live client. + try boClient.dropLocalDatabaseConnection() + + let staticOutcome = try await Client.checkDatabaseIntegrity( + dbPath: boClient.dbPath, encryptionKey: key + ) + XCTAssertEqual(staticOutcome.outcome, "ok") + + try await boClient.reconnectLocalDatabase() + try boClient.deleteLocalDatabase() + } + func testCanMessage() async throws { let fixtures = try await fixtures() let notOnNetwork = try PrivateKey.generate() diff --git a/sdks/js/browser-sdk/src/Client.ts b/sdks/js/browser-sdk/src/Client.ts index 096c23d480..0a21c2f941 100644 --- a/sdks/js/browser-sdk/src/Client.ts +++ b/sdks/js/browser-sdk/src/Client.ts @@ -10,6 +10,8 @@ import { type GroupSyncSummary, type Identifier, type InboxState, + type IntegrityCheckLevel, + type IntegrityCheckOutcome, } from "@xmtp/wasm-bindings"; import { CodecRegistry } from "@/CodecRegistry"; import { HistorySyncUrls } from "@/constants"; @@ -1086,4 +1088,16 @@ export class Client { async syncAllDeviceSyncGroups(): Promise { return this.#worker.action("client.syncAllDeviceSyncGroups"); } + + /** + * Run a read-only integrity check on this client's database + * + * @param level - Check depth, defaults to `IntegrityCheckLevel.Quick` + * @returns Promise that resolves with the outcome and any findings + */ + async dbIntegrityCheck( + level?: IntegrityCheckLevel, + ): Promise { + return this.#worker.action("client.dbIntegrityCheck", { level }); + } } diff --git a/sdks/js/browser-sdk/src/Opfs.ts b/sdks/js/browser-sdk/src/Opfs.ts index 3f3d219b4f..da212be289 100644 --- a/sdks/js/browser-sdk/src/Opfs.ts +++ b/sdks/js/browser-sdk/src/Opfs.ts @@ -1,3 +1,4 @@ +import type { IntegrityCheckLevel } from "@xmtp/wasm-bindings"; import type { OpfsAction } from "@/types/actions/opfs"; import { WorkerBridge } from "@/utils/WorkerBridge"; @@ -60,4 +61,15 @@ export class Opfs { async clearAll() { return this.#worker.action("opfs.clearAll"); } + + /** + * Run a read-only integrity check on a database file without a client. + * + * @param path - Path of the database file in OPFS + * @param level - Check depth, defaults to `IntegrityCheckLevel.Quick` + * @returns Promise that resolves with the outcome and any findings + */ + async checkDatabaseIntegrity(path: string, level?: IntegrityCheckLevel) { + return this.#worker.action("opfs.checkDatabaseIntegrity", { path, level }); + } } diff --git a/sdks/js/browser-sdk/src/WorkerClient.ts b/sdks/js/browser-sdk/src/WorkerClient.ts index 5462ced621..190b1948db 100644 --- a/sdks/js/browser-sdk/src/WorkerClient.ts +++ b/sdks/js/browser-sdk/src/WorkerClient.ts @@ -6,6 +6,8 @@ import { type Client, type GroupSyncSummary, type Identifier, + type IntegrityCheckLevel, + type IntegrityCheckOutcome, type KeyPackageStatus, type SignatureRequestHandle, } from "@xmtp/wasm-bindings"; @@ -257,4 +259,10 @@ export class WorkerClient { async syncAllDeviceSyncGroups(): Promise { return this.#client.device_sync().syncAllDeviceSyncGroups(); } + + async dbIntegrityCheck( + level?: IntegrityCheckLevel, + ): Promise { + return this.#client.dbIntegrityCheck(level); + } } diff --git a/sdks/js/browser-sdk/src/index.ts b/sdks/js/browser-sdk/src/index.ts index 9bf39f5fc4..962bd006ab 100644 --- a/sdks/js/browser-sdk/src/index.ts +++ b/sdks/js/browser-sdk/src/index.ts @@ -41,6 +41,7 @@ export type { Inbox, InboxState, Installation, + IntegrityCheckOutcome, Intent, KeyPackageStatus, LeaveRequest, @@ -80,6 +81,7 @@ export { GroupMessageKind, GroupPermissionsOptions, IdentifierKind, + IntegrityCheckLevel, ListConversationsOrderBy, LogLevel, MessageSortBy, diff --git a/sdks/js/browser-sdk/src/types/actions/client.ts b/sdks/js/browser-sdk/src/types/actions/client.ts index b8eac010a2..e26978c5e8 100644 --- a/sdks/js/browser-sdk/src/types/actions/client.ts +++ b/sdks/js/browser-sdk/src/types/actions/client.ts @@ -4,6 +4,8 @@ import type { AvailableArchiveInfo, GroupSyncSummary, Identifier, + IntegrityCheckLevel, + IntegrityCheckOutcome, KeyPackageStatus, } from "@xmtp/wasm-bindings"; import type { @@ -305,4 +307,12 @@ export type ClientAction = id: string; result: GroupSyncSummary; data: undefined; + } + | { + action: "client.dbIntegrityCheck"; + id: string; + result: IntegrityCheckOutcome; + data: { + level?: IntegrityCheckLevel; + }; }; diff --git a/sdks/js/browser-sdk/src/types/actions/opfs.ts b/sdks/js/browser-sdk/src/types/actions/opfs.ts index 6f94c30602..3dbe91bea7 100644 --- a/sdks/js/browser-sdk/src/types/actions/opfs.ts +++ b/sdks/js/browser-sdk/src/types/actions/opfs.ts @@ -1,3 +1,8 @@ +import type { + IntegrityCheckLevel, + IntegrityCheckOutcome, +} from "@xmtp/wasm-bindings"; + export type OpfsAction = | { action: "opfs.init"; @@ -63,4 +68,13 @@ export type OpfsAction = id: string; result: undefined; data: undefined; + } + | { + action: "opfs.checkDatabaseIntegrity"; + id: string; + result: IntegrityCheckOutcome; + data: { + path: string; + level?: IntegrityCheckLevel; + }; }; diff --git a/sdks/js/browser-sdk/src/workers/client.ts b/sdks/js/browser-sdk/src/workers/client.ts index f2b7fd64ea..2b4e6d7d83 100644 --- a/sdks/js/browser-sdk/src/workers/client.ts +++ b/sdks/js/browser-sdk/src/workers/client.ts @@ -392,6 +392,11 @@ self.onmessage = async ( postMessage({ id, action, result }); break; } + case "client.dbIntegrityCheck": { + const result = await client.dbIntegrityCheck(data.level); + postMessage({ id, action, result }); + break; + } /** * Debug information actions */ diff --git a/sdks/js/browser-sdk/src/workers/opfs.ts b/sdks/js/browser-sdk/src/workers/opfs.ts index 69656a801f..a299257268 100644 --- a/sdks/js/browser-sdk/src/workers/opfs.ts +++ b/sdks/js/browser-sdk/src/workers/opfs.ts @@ -1,4 +1,5 @@ import init, { + checkDatabaseIntegrity, opfsClearAll, opfsDeleteFile, opfsExportDb, @@ -116,6 +117,11 @@ self.onmessage = async ( postMessage({ id, action, result: undefined }); return; } + case "opfs.checkDatabaseIntegrity": { + const result = await checkDatabaseIntegrity(data.path, data.level); + postMessage({ id, action, result }); + return; + } } } catch (e) { postMessageError({ diff --git a/sdks/js/browser-sdk/test/Client.test.ts b/sdks/js/browser-sdk/test/Client.test.ts index afce09c29a..2ccbfebd15 100644 --- a/sdks/js/browser-sdk/test/Client.test.ts +++ b/sdks/js/browser-sdk/test/Client.test.ts @@ -1,4 +1,4 @@ -import { IdentifierKind } from "@xmtp/wasm-bindings"; +import { IdentifierKind, IntegrityCheckLevel } from "@xmtp/wasm-bindings"; import { describe, expect, it } from "vitest"; import { Client } from "@/Client"; import { SignerUnavailableError } from "@/utils/errors"; @@ -470,4 +470,17 @@ describe("Client", () => { expect(inboxUpdatesCounts.get(client.inboxId!)).toBeTypeOf("number"); expect(ownInboxUpdatesCount).toBeTypeOf("number"); }); + + it("should run a database integrity check", async () => { + const { signer } = createSigner(); + const client = await createRegisteredClient(signer); + + const outcome = await client.dbIntegrityCheck(); + expect(outcome.outcome).toBe("ok"); + expect(outcome.findings).toEqual([]); + + const fullOutcome = await client.dbIntegrityCheck(IntegrityCheckLevel.Full); + expect(fullOutcome.outcome).toBe("ok"); + expect(fullOutcome.findings).toEqual([]); + }); }); diff --git a/sdks/js/browser-sdk/test/Opfs.test.ts b/sdks/js/browser-sdk/test/Opfs.test.ts index 04235578d6..e98fd565f1 100644 --- a/sdks/js/browser-sdk/test/Opfs.test.ts +++ b/sdks/js/browser-sdk/test/Opfs.test.ts @@ -1,3 +1,4 @@ +import { IntegrityCheckLevel } from "@xmtp/wasm-bindings"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Opfs } from "@/Opfs"; import { uuid } from "@/utils/uuid"; @@ -135,5 +136,26 @@ describe.skip("Opfs", () => { expect(files2).toHaveLength(0); opfs.close(); }); + + it("should check the integrity of a client database", async () => { + const { signer } = createSigner(); + const dbPath = `./test-${uuid()}.db3`; + const client = await createRegisteredClient(signer, { + dbPath, + }); + client.close(); + const opfs = await Opfs.create(); + const outcome = await opfs.checkDatabaseIntegrity(dbPath); + const fullOutcome = await opfs.checkDatabaseIntegrity( + dbPath, + IntegrityCheckLevel.Full, + ); + await opfs.clearAll(); + opfs.close(); + expect(outcome.outcome).toBe("ok"); + expect(outcome.findings).toEqual([]); + expect(fullOutcome.outcome).toBe("ok"); + expect(fullOutcome.findings).toEqual([]); + }); }); });