1use bon::Builder;
3use diesel::{
4 connection::{LoadConnection, SimpleConnection},
5 deserialize::FromSqlRow,
6 prelude::*,
7 result::{DatabaseErrorKind, Error as DieselError},
8 sql_query,
9};
10use std::{
11 fmt::Display,
12 fs::File,
13 io::{BufReader, Read, Write},
14 path::{Path, PathBuf},
15};
16use xmtp_configuration::BUSY_TIMEOUT;
17
18use super::PlatformStorageError;
19use crate::{
20 NotFound,
21 database::instrumentation::TestInstrumentation,
22 native::{ConnectionOptions, connection_pragmas},
23};
24
25use crate::{EncryptionKey, StorageOption};
26
27pub type Salt = [u8; 16];
28const PLAINTEXT_HEADER_SIZE: usize = 32;
29const SALT_FILE_NAME: &str = "sqlcipher_salt";
30const VALIDATION_QUERY: &str = "SELECT count(*) FROM sqlite_master;";
31
32#[derive(QueryableByName, Debug)]
34struct CipherVersion {
35 #[diesel(sql_type = diesel::sql_types::Text)]
36 cipher_version: String,
37}
38
39#[derive(QueryableByName, Debug)]
41struct CipherProviderVersion {
42 #[diesel(sql_type = diesel::sql_types::Text)]
43 cipher_provider_version: String,
44}
45
46#[derive(Clone, Debug, Builder, zeroize::ZeroizeOnDrop)]
48pub struct EncryptedConnection {
49 key: EncryptionKey,
50 salt: Option<Salt>,
52 options: StorageOption,
53}
54
55impl EncryptedConnection {
56 pub fn new(key: EncryptionKey, opts: &StorageOption) -> Result<Self, PlatformStorageError> {
58 use crate::StorageOption::*;
59
60 let salt = match opts {
61 Ephemeral => None,
62 Persistent(db_path) => {
63 {
64 let mut conn = SqliteConnection::establish(db_path)?;
65 Self::check_for_sqlcipher(opts, &mut conn)?;
66 }
67 let mut salt = [0u8; 16];
68 let db_pathbuf = PathBuf::from(db_path);
69 let salt_path = Self::salt_file(db_path)?;
70
71 match (salt_path.try_exists()?, db_pathbuf.try_exists()?) {
72 (true, true) => {
74 tracing::debug!(
75 salt = %salt_path.display(),
76 db = %db_pathbuf.display(),
77 "salt and database exist, db=[{}], salt=[{}]",
78 db_pathbuf.display(),
79 salt_path.display(),
80 );
81 let file = BufReader::new(File::open(salt_path)?);
82 salt = <Salt as hex::FromHex>::from_hex(
83 file.bytes().take(32).collect::<Result<Vec<u8>, _>>()?,
84 )?;
85 }
86 (false, true) => {
88 tracing::debug!(
89 "migrating sqlcipher db=[{}] to plaintext header with salt=[{}]",
90 db_pathbuf.display(),
91 salt_path.display()
92 );
93 Self::migrate(db_path, &key, &mut salt)?;
94 }
95 (false, false) => {
97 tracing::debug!(
98 "creating new sqlcipher db=[{}] with salt=[{}]",
99 db_pathbuf.display(),
100 salt_path.display()
101 );
102 Self::create(db_path, &key, &mut salt)?;
103 }
104 (true, false) => {
108 tracing::debug!(
109 "database [{}] does not exist, but the salt [{}] does, re-creating",
110 db_pathbuf.display(),
111 salt_path.display(),
112 );
113 std::fs::remove_file(salt_path)?;
114 Self::create(db_path, &key, &mut salt)?;
115 }
116 }
117 tracing::info!("db_path=[{}]", db_path);
118 Some(salt)
119 }
120 };
121
122 Ok(Self {
123 key,
124 salt,
125 options: opts.clone(),
126 })
127 }
128
129 fn create(
132 path: &String,
133 key: &EncryptionKey,
134 salt: &mut [u8],
135 ) -> Result<(), PlatformStorageError> {
136 let conn = &mut SqliteConnection::establish(path)?;
137 conn.batch_execute(&format!(
138 r#"
139 {}
140 {}
141 "#,
142 pragma_key(hex::encode(key)),
143 pragma_plaintext_header()
144 ))?;
145
146 Self::write_salt(path, conn, salt)?;
147 Ok(())
148 }
149
150 fn migrate(
156 path: &String,
157 key: &EncryptionKey,
158 salt: &mut [u8],
159 ) -> Result<(), PlatformStorageError> {
160 let conn = &mut SqliteConnection::establish(path)?;
161
162 conn.batch_execute(&format!(
163 r#"
164 {}
165 select count(*) from sqlite_master; -- trigger header read, currently it is encrypted
166 "#,
167 pragma_key(hex::encode(key))
168 ))?;
169
170 Self::write_salt(path, conn, salt)?;
172
173 conn.batch_execute(&format!(
174 r#"
175 {}
176 PRAGMA user_version = 1; -- force header write
177 "#,
178 pragma_plaintext_header()
179 ))?;
180
181 Ok(())
182 }
183
184 fn write_salt(
187 path: &String,
188 conn: &mut SqliteConnection,
189 buf: &mut [u8],
190 ) -> Result<(), PlatformStorageError> {
191 let mut row_iter = conn.load(sql_query("PRAGMA cipher_salt"))?;
192 let row = row_iter
194 .next()
195 .ok_or(NotFound::CipherSalt(path.to_string()))??;
196 let salt = <String as FromSqlRow<diesel::sql_types::Text, _>>::build_from_row(&row)?;
197 tracing::debug!(
198 salt,
199 file = %Self::salt_file(PathBuf::from(path))?.display(),
200 "writing salt to file"
201 );
202 let mut f = File::create(Self::salt_file(PathBuf::from(path))?)?;
203
204 f.write_all(salt.as_bytes())?;
205 let mut perms = f.metadata()?.permissions();
206 perms.set_readonly(true);
207 f.set_permissions(perms)?;
208
209 let salt = hex::decode(salt)?;
210 buf.copy_from_slice(&salt);
211 Ok(())
212 }
213
214 pub(crate) fn salt_file<P: AsRef<Path>>(db_path: P) -> std::io::Result<PathBuf> {
218 let db_path: &Path = db_path.as_ref();
219 let name = db_path.file_name().ok_or(std::io::Error::new(
220 std::io::ErrorKind::NotFound,
221 "database file has no name",
222 ))?;
223 let db_path = db_path.parent().ok_or(std::io::Error::new(
224 std::io::ErrorKind::NotFound,
225 "Parent directory could not be found",
226 ))?;
227 Ok(db_path.join(format!("{}.{}", name.to_string_lossy(), SALT_FILE_NAME)))
228 }
229
230 fn pragmas(&self) -> impl Display {
232 let Self { key, salt, .. } = self;
233
234 if let Some(s) = salt {
235 format!(
236 "{}\n{}\n{}",
237 pragma_key(hex::encode(key)),
238 pragma_plaintext_header(),
239 pragma_salt(hex::encode(s))
240 )
241 } else {
242 format!(
243 "{}\n{}",
244 pragma_key(hex::encode(key)),
245 pragma_plaintext_header()
246 )
247 }
248 }
249
250 fn check_for_sqlcipher(
251 opts: &StorageOption,
252 conn: &mut SqliteConnection,
253 ) -> Result<CipherVersion, PlatformStorageError> {
254 if cfg!(any(test, feature = "test-utils")) {
255 conn.batch_execute("pragma cipher_log = stdout; pragma cipher_log_level = NONE;")?;
256 }
257
258 if let Some(path) = opts.path() {
259 let exists = std::path::Path::new(path).exists();
260 tracing::debug!("db @ [{}] exists? [{}]", path, exists);
261 }
262 let mut cipher_version = sql_query("PRAGMA cipher_version").load::<CipherVersion>(conn)?;
263 if cipher_version.is_empty() {
264 return Err(PlatformStorageError::SqlCipherNotLoaded);
265 }
266 Ok(cipher_version.pop().expect("checked for empty"))
267 }
268}
269
270impl ConnectionOptions for EncryptedConnection {
271 fn options(&self) -> &StorageOption {
272 &self.options
273 }
274}
275
276impl super::ValidatedConnection for EncryptedConnection {
277 fn validate(&self, conn: &mut SqliteConnection) -> Result<(), PlatformStorageError> {
278 let sqlcipher_version = EncryptedConnection::check_for_sqlcipher(&self.options, conn)?;
279
280 conn.batch_execute(&self.pragmas().to_string())?;
281 conn.batch_execute(&format!("PRAGMA busy_timeout = {BUSY_TIMEOUT};"))?;
284
285 conn.batch_execute(VALIDATION_QUERY)
288 .map_err(validation_error)?;
289
290 let CipherProviderVersion {
291 cipher_provider_version,
292 } = sql_query("PRAGMA cipher_provider_version")
293 .get_result::<CipherProviderVersion>(conn)?;
294 tracing::info!(
295 "Sqlite cipher_version={:?}, cipher_provider_version={:?}",
296 sqlcipher_version.cipher_version,
297 cipher_provider_version
298 );
299 let log = std::env::var("SQLCIPHER_LOG");
300 let is_sqlcipher_log_enabled = matches!(log, Ok(s) if s == "true" || s == "1");
301 if is_sqlcipher_log_enabled {
303 conn.batch_execute("PRAGMA cipher_log = stderr; PRAGMA cipher_log_level = INFO;")
304 .ok();
305 }
306 tracing::debug!("SQLCipher Database validated.");
307 Ok(())
308 }
309}
310
311fn validation_error(error: DieselError) -> PlatformStorageError {
313 tracing::error!("SQLCipher schema validation failed: {error:?}");
314 match error {
319 DieselError::DatabaseError(DatabaseErrorKind::Unknown, ref info)
320 if matches!(
321 info.message(),
322 "file is not a database" | "database disk image is malformed" | "SQL logic error"
323 ) || info.message().starts_with("malformed database schema (") =>
324 {
325 PlatformStorageError::SqlCipherKeyIncorrect
326 }
327 error => PlatformStorageError::DieselResult(error),
328 }
329}
330
331impl diesel::r2d2::CustomizeConnection<SqliteConnection, diesel::r2d2::Error>
332 for EncryptedConnection
333{
334 fn on_acquire(&self, conn: &mut SqliteConnection) -> Result<(), diesel::r2d2::Error> {
335 if cfg!(any(test, feature = "test-utils")) {
336 conn.set_instrumentation(TestInstrumentation);
337 }
338 conn.batch_execute(&format!("{}", self.pragmas(),))
339 .map_err(diesel::r2d2::Error::QueryError)?;
340 connection_pragmas(conn)?;
341 Ok(())
342 }
343}
344
345fn pragma_key(key: impl Display) -> impl Display {
346 format!(r#"PRAGMA key = "x'{key}'";"#)
347}
348
349fn pragma_salt(salt: impl Display) -> impl Display {
350 format!(r#"PRAGMA cipher_salt="x'{salt}'";"#)
351}
352
353fn pragma_plaintext_header() -> impl Display {
354 format!(r#"PRAGMA cipher_plaintext_header_size={PLAINTEXT_HEADER_SIZE};"#)
355}
356
357#[cfg(test)]
358mod tests {
359 use crate::{EncryptedMessageStore, NativeDb, ValidatedConnection, XmtpTestDb};
360 use diesel::connection::InstrumentationEvent;
361 use diesel_migrations::MigrationHarness;
362 use std::{fs::File, sync::mpsc};
363 use xmtp_common::{ErrorCode, RetryableError, time::Duration, tmp_path};
364
365 use super::*;
366 const SQLITE3_PLAINTEXT_HEADER: &str = "SQLite format 3\0";
367 const VALIDATION_KEY: [u8; 32] = [7; 32];
368 const LOCK_PROBE_TIMEOUT: Duration = Duration::from_millis(100);
369 const VALIDATION_START_TIMEOUT: Duration = Duration::from_secs(10);
370 use StorageOption::*;
371
372 fn locked_encrypted_database() -> (String, EncryptedConnection, SqliteConnection) {
374 let path = tmp_path();
375 let customizer =
376 EncryptedConnection::new(VALIDATION_KEY.into(), &Persistent(path.clone())).unwrap();
377 let mut conn = SqliteConnection::establish(&path).unwrap();
378 customizer.validate(&mut conn).unwrap();
379 conn.batch_execute(
380 "CREATE TABLE validation_fixture (value INTEGER NOT NULL);
381 INSERT INTO validation_fixture VALUES (1);
382 PRAGMA journal_mode = WAL;
383 PRAGMA locking_mode = EXCLUSIVE;
384 BEGIN EXCLUSIVE;",
385 )
386 .unwrap();
387 (path, customizer, conn)
388 }
389
390 #[xmtp_common::test(unwrap_try = true)]
391 async fn same_key_reopen_waits_for_transient_lock() {
392 let (path, customizer, locked) = locked_encrypted_database();
393 let mut reopened = SqliteConnection::establish(&path)?;
394 let (started_tx, started_rx) = mpsc::channel();
395 let (finished_tx, finished_rx) = mpsc::channel();
396 reopened.set_instrumentation(move |event: InstrumentationEvent<'_>| {
397 if let InstrumentationEvent::StartQuery { query, .. } = event
398 && query.to_string() == VALIDATION_QUERY
399 {
400 started_tx.send(()).unwrap();
401 }
402 });
403 let validation = std::thread::spawn(move || {
404 let result = customizer.validate(&mut reopened);
405 finished_tx.send(()).unwrap();
406 result.map(|()| reopened)
407 });
408
409 let started = started_rx.recv_timeout(VALIDATION_START_TIMEOUT);
411 let waiting = finished_rx.recv_timeout(LOCK_PROBE_TIMEOUT);
412 drop(locked);
413 let result = validation.join().unwrap();
414 started?;
415 assert!(matches!(waiting, Err(mpsc::RecvTimeoutError::Timeout)));
416 let mut reopened = result?;
417 let timeout = {
418 let mut rows = reopened.load(sql_query("PRAGMA busy_timeout"))?;
419 let row = rows.next().unwrap()?;
420 <i32 as FromSqlRow<diesel::sql_types::Integer, _>>::build_from_row(&row)?
421 };
422 assert_eq!(timeout, BUSY_TIMEOUT);
423 let value = diesel::select(diesel::dsl::sql::<diesel::sql_types::Integer>(
424 "(SELECT value FROM validation_fixture)",
425 ))
426 .get_result::<i32>(&mut reopened)?;
427 assert_eq!(value, 1);
428 drop(reopened);
429 EncryptedMessageStore::<()>::remove_db_files(path);
430 }
431
432 #[xmtp_common::test(unwrap_try = true)]
433 async fn expired_reopen_lock_keeps_database_error() {
434 let (path, _customizer, locked) = locked_encrypted_database();
435 let error = NativeDb::builder()
436 .persistent(path.clone())
437 .key(VALIDATION_KEY)
438 .build()
439 .unwrap_err();
440 drop(locked);
441 assert!(error.is_retryable());
442 assert!(matches!(
443 error,
444 crate::StorageError::Platform(PlatformStorageError::DieselResult(
445 DieselError::DatabaseError(DatabaseErrorKind::Unknown, ref info)
446 )) if info.message() == "database is locked"
447 ));
448 EncryptedMessageStore::<()>::remove_db_files(path);
449 }
450
451 #[rstest::rstest]
452 #[case("file is not a database")]
453 #[case("database disk image is malformed")]
454 #[case("malformed database schema (identity) - invalid rootpage")]
455 #[xmtp_common::test(unwrap_try = true)]
456 async fn schema_corruption_stays_non_retryable(#[case] message: &str) {
457 let error = validation_error(DieselError::DatabaseError(
458 DatabaseErrorKind::Unknown,
459 Box::new(message.to_string()),
460 ));
461 assert!(matches!(error, PlatformStorageError::SqlCipherKeyIncorrect));
462 assert_eq!(
463 error.error_code(),
464 "PlatformStorageError::SqlCipherKeyIncorrect"
465 );
466 assert!(!error.is_retryable());
467 }
468
469 #[tokio::test]
470 async fn test_sqlcipher_version() {
471 let db_path = tmp_path();
472 {
473 let opts = Persistent(db_path.clone());
474 let mut conn = SqliteConnection::establish(&db_path).unwrap();
475 let v = EncryptedConnection::check_for_sqlcipher(&opts, &mut conn).unwrap();
476 println!("SQLCipher Version {}", v.cipher_version);
477 }
478 }
479
480 #[tokio::test]
481 async fn test_db_creates_with_plaintext_header() {
482 let db_path = tmp_path();
483 {
484 let _ = crate::TestDb::create_persistent_store(Some(db_path.clone())).await;
485
486 assert!(EncryptedConnection::salt_file(&db_path).unwrap().exists());
487 let bytes = std::fs::read(EncryptedConnection::salt_file(&db_path).unwrap()).unwrap();
488 let salt = hex::decode(bytes).unwrap();
489 assert_eq!(salt.len(), 16);
490
491 let mut plaintext_header = [0; 16];
492 let mut file = File::open(&db_path).unwrap();
493 file.read_exact(&mut plaintext_header).unwrap();
494
495 assert_eq!(
496 SQLITE3_PLAINTEXT_HEADER,
497 String::from_utf8(plaintext_header.into()).unwrap()
498 );
499 }
500 EncryptedMessageStore::<()>::remove_db_files(db_path)
501 }
502
503 #[xmtp_common::test(unwrap_try = true)]
504 async fn test_db_migrates() {
505 use crate::{ConnectionExt, XmtpDb};
506 use diesel::sql_types::{Integer, Text};
507 use std::collections::BTreeMap;
508
509 #[derive(QueryableByName)]
510 struct TableName {
511 #[diesel(sql_type = Text)]
512 name: String,
513 }
514 #[derive(QueryableByName)]
515 struct Column {
516 #[diesel(sql_type = Text)]
517 name: String,
518 #[diesel(sql_type = Text)]
519 r#type: String,
520 #[diesel(sql_type = Integer)]
521 notnull: i32,
522 #[diesel(sql_type = Integer)]
523 pk: i32,
524 }
525
526 let db = NativeDb::builder().ephemeral().build_unencrypted()?;
527 db.init()?;
528 let actual = db.conn().raw_query(|conn| {
529 let tables = diesel::sql_query(
530 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != '__diesel_schema_migrations' ORDER BY name",
531 ).load::<TableName>(conn)?;
532 let mut schema = BTreeMap::new();
533 for table in tables {
534 let columns = diesel::sql_query(format!("PRAGMA table_info('{}')", table.name))
535 .load::<Column>(conn)?;
536 let has_primary_key = columns.iter().any(|column| column.pk != 0);
537 let mut columns: Vec<_> = columns.into_iter().map(|column| {
538 let sql_type = match column.r#type.to_uppercase().as_str() {
539 "INT" | "INTEGER" => "Integer",
540 "BIGINT" => "BigInt",
541 "BLOB" => "Binary",
542 "TEXT" => "Text",
543 "BOOL" | "BOOLEAN" => "Bool",
544 "REAL" => "Float",
545 other => panic!("unexpected SQL type {other}"),
546 };
547 let sql_type = if column.notnull == 0 {
548 format!("Nullable<{sql_type}>")
549 } else { sql_type.to_string() };
550 (column.name, sql_type)
551 }).collect();
552 if !has_primary_key { columns.insert(0, ("rowid".into(), "Integer".into())); }
553 schema.insert(table.name, columns);
554 }
555 diesel::sql_query("SELECT * FROM conversation_list").execute(conn)?;
556 assert_eq!(conn.applied_migrations().unwrap().len(), 1);
557 Ok(schema)
558 })?;
559
560 let mut expected = BTreeMap::new();
561 let mut table = None;
562 for line in include_str!("../../schema_gen.rs").lines().map(str::trim) {
563 if line.ends_with("{") && line.contains(" (") {
564 let name = line.split_once(" (").unwrap().0.to_string();
565 expected.insert(name.clone(), Vec::new());
566 table = Some(name);
567 } else if line == "}" {
568 table = None;
569 } else if table.is_some()
570 && let Some((name, sql_type)) = line.split_once(" -> ")
571 {
572 expected
573 .get_mut(table.as_ref().unwrap())
574 .unwrap()
575 .push((name.to_string(), sql_type.trim_end_matches(',').to_string()));
576 }
577 }
578 assert_eq!(actual, expected);
579 }
580
581 #[xmtp_common::test(unwrap_try = true)]
582 async fn rejects_pre_transition_database_before_migration() {
583 use crate::{ConnectionExt, StorageError, XmtpDb};
584 use xmtp_common::{ErrorCode, RetryableError};
585
586 let path = tmp_path();
587 let db = NativeDb::builder()
588 .persistent(path.clone())
589 .build_unencrypted()?;
590 db.conn().raw_query(|conn| conn.batch_execute(
591 "CREATE TABLE __diesel_schema_migrations (version VARCHAR(50) PRIMARY KEY NOT NULL, run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); INSERT INTO __diesel_schema_migrations (version) VALUES ('20250820174800');",
592 ))?;
593 let result = EncryptedMessageStore::new(db);
594 let error = match result {
595 Err(error) => error,
596 Ok(_) => panic!("legacy database was accepted"),
597 };
598 assert!(matches!(error, StorageError::PreTransitionDatabase));
599 assert_eq!(error.error_code(), "StorageError::PreTransitionDatabase");
600 assert!(!error.is_retryable());
601 let mut conn = SqliteConnection::establish(&path)?;
602 assert!(
603 diesel::sql_query("SELECT * FROM group_messages")
604 .execute(&mut conn)
605 .is_err()
606 );
607 assert_eq!(conn.applied_migrations()?.len(), 1);
608 drop(conn);
609 std::fs::remove_file(path)?;
610 }
611
612 #[xmtp_common::test(unwrap_try = true)]
613 async fn rejects_old_self_hosted_format_without_changing_data() {
614 use crate::encrypted_store::EmbeddedMigrationsExt;
615 use crate::{ConnectionExt, StorageError, TestDb, XmtpDb, XmtpTestDb};
616 use diesel::sql_types::Text;
617
618 let database = TestDb::create_database(None).await;
619 let connection = database.conn();
620 connection.raw_query(|conn| {
621 conn.batch_execute(
622 "CREATE TABLE __diesel_schema_migrations (version VARCHAR(50) PRIMARY KEY NOT NULL, run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); CREATE TABLE refresh_state (entity_id BLOB NOT NULL, entity_kind INTEGER NOT NULL, sequence_id BIGINT NOT NULL, PRIMARY KEY(entity_id, entity_kind)); INSERT INTO refresh_state VALUES (x'01', 2, 42);",
623 )?;
624 diesel::sql_query("INSERT INTO __diesel_schema_migrations(version) VALUES (?)")
625 .bind::<Text, _>(crate::MIGRATIONS.final_migration()).execute(conn)?;
626 Ok(())
627 })?;
628 let result = EncryptedMessageStore::new(database);
629 assert!(matches!(result, Err(StorageError::OldStreamDatabase)));
630 let value = connection.raw_query(|conn| {
631 crate::schema::refresh_state::table
632 .select(crate::schema::refresh_state::sequence_id)
633 .first::<i64>(conn)
634 })?;
635 assert_eq!(value, 42);
636 assert!(
637 connection
638 .raw_query(
639 |conn| diesel::sql_query("SELECT * FROM incoming_envelopes").execute(conn)
640 )
641 .is_err()
642 );
643 }
644
645 #[xmtp_common::test(unwrap_try = true)]
651 async fn rejects_a_database_missing_the_server_configuration_table() {
652 use crate::encrypted_store::EmbeddedMigrationsExt;
653 use crate::{ConnectionExt, StorageError, TestDb, XmtpDb, XmtpTestDb};
654 use diesel::sql_types::Text;
655
656 let database = TestDb::create_database(None).await;
657 let connection = database.conn();
658 connection.raw_query(|conn| {
659 conn.batch_execute(
660 "CREATE TABLE __diesel_schema_migrations (version VARCHAR(50) PRIMARY KEY NOT NULL, run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); CREATE TABLE refresh_state (entity_id BLOB NOT NULL, entity_kind INTEGER NOT NULL, sequence_id BIGINT NOT NULL, received_sequence_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY(entity_id, entity_kind));",
661 )?;
662 diesel::sql_query("INSERT INTO __diesel_schema_migrations(version) VALUES (?)")
663 .bind::<Text, _>(crate::MIGRATIONS.final_migration())
664 .execute(conn)?;
665 Ok(())
666 })?;
667 let result = EncryptedMessageStore::new(database);
668 assert!(matches!(result, Err(StorageError::OldStreamDatabase)));
669 }
670}