diff --git a/Makefile b/Makefile index 5e19d70af0..d2d4ed02a0 100644 --- a/Makefile +++ b/Makefile @@ -469,7 +469,8 @@ $(TS_OUT): $(ts-deps) --target browser \ --format esm \ --outdir out \ - --entry-naming "corecrypto.[ext]" && \ + --entry-naming "corecrypto.[ext]" \ + --external env && \ mkdir -p out/autogenerated/ && \ mkdir -p out/autogenerated/wasm-bindgen && \ cp src/autogenerated/wasm-bindgen/index_bg.wasm out/autogenerated/wasm-bindgen/ && \ diff --git a/crypto-ffi/bindings/js/src/CoreCrypto.ts b/crypto-ffi/bindings/js/src/CoreCrypto.ts index fb2e07db10..b45509ea58 100644 --- a/crypto-ffi/bindings/js/src/CoreCrypto.ts +++ b/crypto-ffi/bindings/js/src/CoreCrypto.ts @@ -92,6 +92,7 @@ export { type MlsTransportData, migrateDatabaseKeyTypeToBytes, updateDatabaseKey, + exportDatabaseCopy, NewAcmeAuthz, NewAcmeOrder, ExternalSenderKey, diff --git a/crypto-ffi/src/database/mod.rs b/crypto-ffi/src/database/mod.rs index d25fa3411d..6d7f7bc2a7 100644 --- a/crypto-ffi/src/database/mod.rs +++ b/crypto-ffi/src/database/mod.rs @@ -41,3 +41,42 @@ pub async fn open_database(name: &str, key: Arc) -> CoreCryptoResul pub async fn in_memory_database(key: Arc) -> CoreCryptoResult { Database::in_memory(key).await } + +/// Export a copy of the database to the specified path. +/// +/// This creates a fully vacuumed and optimized copy of the database using SQLite's VACUUM INTO command. +/// The copy will be encrypted with the same key as the source database. +/// +/// # Platform Support +/// This method is only available on platforms using SQLCipher (iOS, Android, JVM, native). +/// On WASM platforms, this function will return an error. +/// +/// # Arguments +/// * `database` - The database instance to export +/// * `destination_path` - The file path where the database copy should be created +/// +/// # Errors +/// Returns an error if: +/// - Called on WASM platform (not supported) +/// - The database is in-memory (cannot export in-memory databases) +/// - The destination path is invalid or not writable +/// - The export operation fails +#[uniffi::export] +pub async fn export_database_copy(database: &Database, destination_path: &str) -> CoreCryptoResult<()> { + #[cfg(target_family = "wasm")] + { + let _ = (database, destination_path); // Suppress unused warnings + Err(CoreCryptoError::ad_hoc( + "export_database_copy is not supported on WASM. This function requires filesystem operations and SQLCipher, which are only available on native platforms (iOS, Android, JVM).", + )) + } + + #[cfg(not(target_family = "wasm"))] + { + database + .0 + .export_copy(destination_path) + .await + .map_err(CoreCryptoError::generic()) + } +} diff --git a/crypto-ffi/src/lib.rs b/crypto-ffi/src/lib.rs index 8ba946d576..6e838bd218 100644 --- a/crypto-ffi/src/lib.rs +++ b/crypto-ffi/src/lib.rs @@ -55,7 +55,8 @@ pub use credential_ref::CredentialRef; pub use credential_type::CredentialType; pub use crl::CrlRegistration; pub use database::{ - Database, DatabaseKey, in_memory_database, migrate_database_key_type_to_bytes, open_database, update_database_key, + Database, DatabaseKey, export_database_copy, in_memory_database, migrate_database_key_type_to_bytes, open_database, + update_database_key, }; pub use decrypted_message::{BufferedDecryptedMessage, DecryptedMessage}; pub use e2ei::{ diff --git a/keystore/src/connection/mod.rs b/keystore/src/connection/mod.rs index 24d51d2756..dfafee843b 100644 --- a/keystore/src/connection/mod.rs +++ b/keystore/src/connection/mod.rs @@ -283,6 +283,27 @@ impl Database { self.take().await?.wipe().await } + /// Export a copy of the database to the specified path. + /// This creates a fully vacuumed and optimized copy of the database. + /// The copy will be encrypted with the same key as the source database. + /// + /// # Platform Support + /// This method is only available on platforms using SQLCipher (not WASM). + /// + /// # Arguments + /// * `destination_path` - The file path where the database copy should be created + /// + /// # Errors + /// Returns an error if: + /// - The database is in-memory (cannot export in-memory databases) + /// - The destination path is invalid + /// - The export operation fails + #[cfg(not(target_family = "wasm"))] + pub async fn export_copy(&self, destination_path: &str) -> CryptoKeystoreResult<()> { + let conn = self.conn().await?; + conn.export_copy(destination_path).await + } + pub async fn migrate_db_key_type_to_bytes( name: &str, old_key: &str, diff --git a/keystore/src/connection/platform/generic/mod.rs b/keystore/src/connection/platform/generic/mod.rs index dfd1ac1713..1e173b58b9 100644 --- a/keystore/src/connection/platform/generic/mod.rs +++ b/keystore/src/connection/platform/generic/mod.rs @@ -234,6 +234,26 @@ impl SqlCipherConnection { Ok(()) } + /// Export a copy of the database to the specified path using VACUUM INTO. + /// + /// This creates a fully vacuumed and optimized copy of the database. + /// The copy will be encrypted with the same key as the source database. + /// + /// # Arguments + /// * `destination_path` - The file path where the database copy should be created + /// + /// # Errors + /// Returns an error if: + /// - The database is in-memory (cannot export in-memory databases) + /// - The destination path is invalid + /// - The VACUUM INTO operation fails + pub async fn export_copy(&self, destination_path: &str) -> CryptoKeystoreResult<()> { + let conn = self.conn().await; + conn.execute("VACUUM INTO ?1", [destination_path])?; + + Ok(()) + } + fn run_migrations(conn: &mut rusqlite::Connection, target: MigrationTarget) -> CryptoKeystoreResult<()> { conn.create_scalar_function("sha256_blob", 1, FunctionFlags::SQLITE_DETERMINISTIC, |ctx| { let input_blob = ctx.get::>(0)?; @@ -327,8 +347,11 @@ mod migration_test { entities::{EntityFindParams, StoredCredential}, }; - const DB: &[u8] = include_bytes!("../../../../../crypto-ffi/bindings/jvm/src/test/resources/db-v10002003.sqlite"); - const OLD_KEY: &str = "secret"; + pub(super) const DB: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../crypto-ffi/bindings/jvm/src/test/resources/db-v10002003.sqlite" + )); + pub(super) const OLD_KEY: &str = "secret"; // a close replica of the JVM test in `GeneralTest.kt`, but way more debuggable #[test] @@ -456,3 +479,90 @@ mod migration_test { }); } } + +#[cfg(test)] +mod export_test { + use futures_lite::future; + + use crate::{ConnectionType, Database, DatabaseKey}; + + #[test] + fn can_export_database_copy() { + future::block_on(async { + // Create temporary directory + let temp_dir = tempfile::tempdir().unwrap(); + let source_path = temp_dir.path().join("test_export_source.db"); + let dest_path = temp_dir.path().join("test_export_dest.db"); + + // Write test database + std::fs::write(&source_path, super::migration_test::DB).unwrap(); + + // Migrate the database to use the new key format + let key = DatabaseKey::generate(); + Database::migrate_db_key_type_to_bytes(source_path.to_str().unwrap(), super::migration_test::OLD_KEY, &key) + .await + .unwrap(); + + // Open the database + let db = Database::open(ConnectionType::Persistent(source_path.to_str().unwrap()), &key) + .await + .unwrap(); + + // Insert test data into a test table + let test_data = b"test data for export verification"; + let test_id = 12345; + { + let conn = db.conn().await.unwrap(); + let conn_guard = conn.conn().await; + + // Create a test table + conn_guard + .execute( + "CREATE TABLE IF NOT EXISTS test_export_data (id INTEGER PRIMARY KEY, data BLOB)", + [], + ) + .unwrap(); + + // Insert test data + conn_guard + .execute( + "INSERT INTO test_export_data (id, data) VALUES (?1, ?2)", + [&test_id as &dyn rusqlite::ToSql, &test_data.as_slice()], + ) + .unwrap(); + } + + // Export the database + db.export_copy(dest_path.to_str().unwrap()).await.unwrap(); + + // Verify the exported database can be opened with the same key + let exported_db = Database::open(ConnectionType::Persistent(dest_path.to_str().unwrap()), &key) + .await + .unwrap(); + + // Read the data from the exported database + { + let conn = exported_db.conn().await.unwrap(); + let conn_guard = conn.conn().await; + + let mut stmt = conn_guard + .prepare("SELECT id, data FROM test_export_data WHERE id = ?1") + .unwrap(); + let mut rows = stmt.query([test_id]).unwrap(); + + let row = rows.next().unwrap().expect("Expected row to exist"); + let read_id: i32 = row.get(0).unwrap(); + let read_data: Vec = row.get(1).unwrap(); + + assert_eq!(read_id, test_id, "ID should match in exported database"); + assert_eq!(read_data, test_data, "Data should match in exported database"); + } + + // Close databases before cleanup + drop(db); + drop(exported_db); + + // temp_dir is automatically cleaned up when it goes out of scope + }); + } +} diff --git a/keystore/src/error.rs b/keystore/src/error.rs index cc3c55be2e..43db01947a 100644 --- a/keystore/src/error.rs +++ b/keystore/src/error.rs @@ -135,6 +135,8 @@ pub enum CryptoKeystoreError { JsError(String), #[error("Not implemented (and probably never will)")] NotImplemented, + #[error("Operation not supported: {0}")] + NotSupported(String), #[error("Failed getting current timestamp")] TimestampError, #[error("Could not find {0} in keystore with value {1}")]