Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions bindings/mobile/src/mls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,73 @@ pub async fn create_client(
}))
}

#[derive(uniffi::Enum, Debug, Clone, Copy)]
pub enum FfiIntegrityCheckLevel {
Quick,
Full,
}

impl From<FfiIntegrityCheckLevel> 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<String>,
}

impl From<xmtp_db::prelude::IntegrityCheckResult> 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<Vec<u8>>,
level: Option<FfiIntegrityCheckLevel>,
) -> Result<FfiIntegrityCheckOutcome, FfiError> {
// Same 32-byte validation and error message as `create_client`'s key
// conversion.
let key = encryption_key
.map(|k| -> Result<EncryptionKey, String> {
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)]
Expand Down Expand Up @@ -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<FfiIntegrityCheckLevel>,
) -> Result<FfiIntegrityCheckOutcome, FfiError> {
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`.
Expand Down
86 changes: 86 additions & 0 deletions bindings/mobile/src/mls/tests/integrity.rs
Original file line number Diff line number Diff line change
@@ -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<u8> = 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<u8> = 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<u8> = EncryptedMessageStore::<()>::generate_enc_key().into();
let outcome = check_database_integrity(db_path, Some(wrong_key), None)
.await
.unwrap();
assert_eq!(outcome.outcome, "unreadable");
}
1 change: 1 addition & 0 deletions bindings/mobile/src/mls/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ mod content_types;
mod dms;
mod group_management;
mod identity;
mod integrity;
mod lifecycle;
mod networking;
mod static_methods;
Expand Down
87 changes: 87 additions & 0 deletions bindings/wasm/src/integrity.rs
Original file line number Diff line number Diff line change
@@ -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<IntegrityCheckLevel> 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<String>,
}

impl From<xmtp_db::prelude::IntegrityCheckResult> 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<IntegrityCheckLevel>,
) -> Result<IntegrityCheckOutcome, JsError> {
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<IntegrityCheckLevel>,
) -> Result<IntegrityCheckOutcome, JsError> {
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())
}
1 change: 1 addition & 0 deletions bindings/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(), 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)
Expand Down
Loading
Loading