//! # Import/export module. use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::pin::Pin; use anyhow::{Context as _, Result, bail, ensure, format_err}; use futures::TryStreamExt; use futures_lite::FutureExt; use pin_project::pin_project; use tokio::fs::{self, File}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio_tar::Archive; use crate::blob::BlobDirContents; use crate::chat::delete_and_reset_all_device_msgs; use crate::config::Config; use crate::context::Context; use crate::e2ee; use crate::events::EventType; use crate::key::{self, DcKey, SignedSecretKey}; use crate::log::{LogExt, warn}; use crate::qr::DCBACKUP_VERSION; use crate::sql; use crate::tools::{ TempPathGuard, create_folder, delete_file, get_filesuffix_lc, read_file, time, usize_to_u64, write_file, }; mod transfer; use ::pgp::types::KeyDetails; pub use transfer::{BackupProvider, get_backup}; // Name of the database file in the backup. const DBFILE_BACKUP_NAME: &str = "dc_database_backup.sqlite"; pub(crate) const BLOBS_BACKUP_NAME: &str = "blobs_backup"; /// Import/export command. #[derive(Debug, Display, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] #[repr(u32)] pub enum ImexMode { /// Export all private keys and all public keys of the user to the /// directory given as `path`. The default key is written to the files /// `{public,private}-key--default-.asc`, if there are more keys, they are /// written to files as `{public,private}-key---.asc`. ExportSelfKeys = 1, /// Import private keys found in `path` if it is a directory, otherwise import a private key /// from `path`. /// The last imported key is made the default keys unless its name contains the string `legacy`. /// Public keys are not imported. ImportSelfKeys = 2, /// Export a backup to the directory given as `path` with the given `passphrase`. /// The backup contains all contacts, chats, images and other data and device independent settings. /// The backup does not contain device dependent settings as ringtones or LED notification settings. /// The name of the backup is `delta-chat-backup---.tar`. ExportBackup = 11, /// `path` is the file (not: directory) to import. The file is normally /// created by DC_IMEX_EXPORT_BACKUP and detected by imex_has_backup(). Importing a backup /// is only possible as long as the context is not configured or used in another way. ImportBackup = 12, } /// Import/export things. /// /// What to do is defined by the `what` parameter. /// /// During execution of the job, /// some events are sent out: /// /// - A number of `DC_EVENT_IMEX_PROGRESS` events are sent and may be used to create /// a progress bar or stuff like that. Moreover, you'll be informed when the imex-job is done. /// /// - For each file written on export, the function sends `DC_EVENT_IMEX_FILE_WRITTEN` /// /// Only one import-/export-progress can run at the same time. /// To cancel an import-/export-progress, drop the future returned by this function. pub async fn imex( context: &Context, what: ImexMode, path: &Path, passphrase: Option, ) -> Result<()> { let cancel = context.alloc_ongoing().await?; let res = { let _guard = context.scheduler.pause(context).await?; imex_inner(context, what, path, passphrase) .race(async { cancel.recv().await.ok(); Err(format_err!("canceled")) }) .await }; context.free_ongoing().await; if let Err(err) = res.as_ref() { // We are using Anyhow's .context() and to show the inner error, too, we need the {:#}: error!(context, "{:#}", err); warn!(context, "IMEX failed to complete: {:#}", err); context.emit_event(EventType::ImexProgress(0)); } else { info!(context, "IMEX successfully completed"); context.emit_event(EventType::ImexProgress(1000)); } res } /// Returns the filename of the backup found (otherwise an error) pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result { let mut dir_iter = tokio::fs::read_dir(dir_name).await?; let mut newest_backup_name = "".to_string(); let mut newest_backup_path: Option = None; while let Ok(Some(dirent)) = dir_iter.next_entry().await { let path = dirent.path(); let name = dirent.file_name(); let name: String = name.to_string_lossy().into(); if name.starts_with("delta-chat") && name.ends_with(".tar") && (newest_backup_name.is_empty() || name > newest_backup_name) { // We just use string comparison to determine which backup is newer. // This works fine because the filenames have the form `delta-chat-backup-2023-10-18-00-foo@example.com.tar` newest_backup_path = Some(path); newest_backup_name = name; } } match newest_backup_path { Some(path) => Ok(path.to_string_lossy().into_owned()), None => bail!("no backup found in {}", dir_name.display()), } } async fn set_self_key(context: &Context, armored: &str) -> Result<()> { let secret_key = SignedSecretKey::from_asc(armored)?; key::store_self_keypair(context, &secret_key).await?; info!( context, "Stored self key: {:?}.", secret_key.public_key().fingerprint() ); Ok(()) } async fn imex_inner( context: &Context, what: ImexMode, path: &Path, passphrase: Option, ) -> Result<()> { info!( context, "{} path: {}", match what { ImexMode::ExportSelfKeys | ImexMode::ExportBackup => "Export", ImexMode::ImportSelfKeys | ImexMode::ImportBackup => "Import", }, path.display() ); ensure!(context.sql.is_open().await, "Database not opened."); context.emit_event(EventType::ImexProgress(1)); if what == ImexMode::ExportBackup || what == ImexMode::ExportSelfKeys { // before we export anything, make sure the private key exists e2ee::ensure_secret_key_exists(context) .await .context("Cannot create private key or private key not available")?; create_folder(context, path).await?; } match what { ImexMode::ExportSelfKeys => export_self_keys(context, path).await, ImexMode::ImportSelfKeys => import_self_keys(context, path).await, ImexMode::ExportBackup => { export_backup(context, path, passphrase.unwrap_or_default()).await } ImexMode::ImportBackup => { import_backup(context, path, passphrase.unwrap_or_default()).await } } } /// Imports backup into the currently open database. /// /// The contents of the currently open database will be lost. /// /// `passphrase` is the passphrase used to open backup database. If backup is unencrypted, pass /// empty string here. async fn import_backup( context: &Context, backup_to_import: &Path, passphrase: String, ) -> Result<()> { let backup_file = File::open(backup_to_import).await?; let file_size = backup_file.metadata().await?.len(); info!( context, "Import \"{}\" ({} bytes) to \"{}\".", backup_to_import.display(), file_size, context.get_dbfile().display() ); import_backup_stream(context, backup_file, file_size, passphrase).await?; Ok(()) } /// Imports backup by reading a tar file from a stream. /// /// `file_size` is used to calculate the progress /// and emit progress events. /// Ideally it is the sum of the entry /// sizes without the header overhead, /// but can be estimated as tar file size /// in which case the progress is underestimated /// and may not reach 99.9% by the end of import. /// Underestimating is better than /// overestimating because the progress /// jumps to 100% instead of getting stuck at 99.9% /// for some time. pub(crate) async fn import_backup_stream( context: &Context, backup_file: R, file_size: u64, passphrase: String, ) -> Result<()> { ensure!( !context.is_configured().await?, "Cannot import backups to accounts in use" ); ensure!( !context.scheduler.is_running().await, "Cannot import backup, IO is running" ); import_backup_stream_inner(context, backup_file, file_size, passphrase) .await .0 } /// Reader that emits progress events as bytes are read from it. #[pin_project] struct ProgressReader { /// Wrapped reader. #[pin] inner: R, /// Number of bytes successfully read from the internal reader. read: u64, /// Total size of the backup .tar file expected to be read from the reader. /// Used to calculate the progress. file_size: u64, /// Last progress emitted to avoid emitting the same progress value twice. last_progress: u16, /// Context for emitting progress events. context: Context, } impl ProgressReader { fn new(r: R, context: Context, file_size: u64) -> Self { Self { inner: r, read: 0, file_size, last_progress: 1, context, } } } impl AsyncRead for ProgressReader where R: AsyncRead, { #[expect(clippy::arithmetic_side_effects)] fn poll_read( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut ReadBuf<'_>, ) -> std::task::Poll> { let this = self.project(); let before = buf.filled().len(); let res = this.inner.poll_read(cx, buf); if let std::task::Poll::Ready(Ok(())) = res { *this.read = this .read .saturating_add(usize_to_u64(buf.filled().len() - before)); let progress = std::cmp::min(1000 * *this.read / *this.file_size, 999) as u16; if progress > *this.last_progress { this.context.emit_event(EventType::ImexProgress(progress)); *this.last_progress = progress; } } res } } // This function returns a tuple (Result<()>,) rather than Result<()> // so that we don't accidentally early-return with `?` // and forget to cleanup. async fn import_backup_stream_inner( context: &Context, backup_file: R, file_size: u64, passphrase: String, ) -> (Result<()>,) { let backup_file = ProgressReader::new(backup_file, context.clone(), file_size); let mut archive = Archive::new(backup_file); let mut entries = match archive.entries() { Ok(entries) => entries, Err(e) => return (Err(e).context("Failed to get archive entries"),), }; let mut blobs = Vec::new(); let mut res: Result<()> = loop { let mut f = match entries.try_next().await { Ok(Some(f)) => f, Ok(None) => break Ok(()), Err(e) => break Err(e).context("Failed to get next entry"), }; let path = match f.path() { Ok(path) => path.to_path_buf(), Err(e) => break Err(e).context("Failed to get entry path"), }; if let Err(e) = f.unpack_in(context.get_blobdir()).await { break Err(e).context("Failed to unpack file"); } if path.file_name() == Some(OsStr::new(DBFILE_BACKUP_NAME)) { continue; } // async_tar unpacked to $BLOBDIR/BLOBS_BACKUP_NAME/, so we move the file afterwards. let from_path = context.get_blobdir().join(&path); if from_path.is_file() { if let Some(name) = from_path.file_name() { let to_path = context.get_blobdir().join(name); if let Err(e) = fs::rename(&from_path, &to_path).await { blobs.push(from_path); break Err(e).context("Failed to move file to blobdir"); } blobs.push(to_path); } else { warn!(context, "No file name"); } } }; let unpacked_database = context.get_blobdir().join(DBFILE_BACKUP_NAME); if res.is_ok() { res = context .sql .import(&unpacked_database, passphrase.clone()) .await .context("cannot import unpacked database"); } if res.is_ok() { res = check_backup_version(context).await; } fs::remove_file(unpacked_database) .await .context("cannot remove unpacked database") .log_err(context) .ok(); if res.is_ok() { context.emit_event(EventType::ImexProgress(999)); res = context.sql.run_migrations(context).await; context.emit_event(EventType::AccountsItemChanged); } if res.is_err() { context.sql.close().await; fs::remove_file(context.sql.dbfile.as_path()) .await .log_err(context) .ok(); for blob in blobs { fs::remove_file(&blob).await.log_err(context).ok(); } context .sql .open(context, "".to_string()) .await .log_err(context) .ok(); } if res.is_ok() { delete_and_reset_all_device_msgs(context) .await .log_err(context) .ok(); } (res,) } /******************************************************************************* * Export backup ******************************************************************************/ /// Returns Ok((temp_db_path, temp_path, dest_path)) on success. Unencrypted database can be /// written to temp_db_path. The backup can then be written to temp_path. If the backup succeeded, /// it can be renamed to dest_path. This guarantees that the backup is complete. fn get_next_backup_path( folder: &Path, addr: &str, backup_time: i64, ) -> Result<(PathBuf, PathBuf, PathBuf)> { let folder = PathBuf::from(folder); let stem = chrono::DateTime::::from_timestamp(backup_time, 0) .context("can't get next backup path")? // Don't change this file name format, in `dc_imex_has_backup` we use string comparison to determine which backup is newer: .format("delta-chat-backup-%Y-%m-%d") .to_string(); // 64 backup files per day should be enough for everyone for i in 0..64 { let mut tempdbfile = folder.clone(); tempdbfile.push(format!("{stem}-{i:02}-{addr}.db")); let mut tempfile = folder.clone(); tempfile.push(format!("{stem}-{i:02}-{addr}.tar.part")); let mut destfile = folder.clone(); destfile.push(format!("{stem}-{i:02}-{addr}.tar")); if !tempdbfile.exists() && !tempfile.exists() && !destfile.exists() { return Ok((tempdbfile, tempfile, destfile)); } } bail!("could not create backup file, disk full?"); } /// Exports the database to a separate file with the given passphrase. /// /// Set passphrase to empty string to export the database unencrypted. #[expect(clippy::arithmetic_side_effects)] async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Result<()> { // get a fine backup file name (the name includes the date so that multiple backup instances are possible) let now = time(); let self_addr = context.get_primary_self_addr().await?; let (temp_db_path, temp_path, dest_path) = get_next_backup_path(dir, &self_addr, now)?; let temp_db_path = TempPathGuard::new(temp_db_path); let temp_path = TempPathGuard::new(temp_path); export_database(context, &temp_db_path, passphrase, now) .await .context("could not export database")?; info!( context, "Backup '{}' to '{}'.", context.get_dbfile().display(), dest_path.display(), ); let file = File::create(&temp_path).await?; let blobdir = BlobDirContents::new(context).await?; let mut file_size = 0; file_size += temp_db_path.metadata()?.len(); for blob in blobdir.iter() { file_size += blob.to_abs_path().metadata()?.len() } export_backup_stream(context, &temp_db_path, blobdir, file, file_size) .await .context("Exporting backup to file failed")?; fs::rename(temp_path, &dest_path).await?; context.emit_event(EventType::ImexFileWritten(dest_path)); Ok(()) } /// Writer that emits progress events as bytes are written into it. #[pin_project] struct ProgressWriter { /// Wrapped writer. #[pin] inner: W, /// Number of bytes successfully written into the internal writer. written: u64, /// Total size of the backup .tar file expected to be written into the writer. /// Used to calculate the progress. file_size: u64, /// Last progress emitted to avoid emitting the same progress value twice. last_progress: u16, /// Context for emitting progress events. context: Context, } impl ProgressWriter { fn new(w: W, context: Context, file_size: u64) -> Self { Self { inner: w, written: 0, file_size, last_progress: 1, context, } } } impl AsyncWrite for ProgressWriter where W: AsyncWrite, { #[expect(clippy::arithmetic_side_effects)] fn poll_write( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { let this = self.project(); let res = this.inner.poll_write(cx, buf); if let std::task::Poll::Ready(Ok(written)) = res { *this.written = this.written.saturating_add(usize_to_u64(written)); let progress = std::cmp::min(1000 * *this.written / *this.file_size, 999) as u16; if progress > *this.last_progress { this.context.emit_event(EventType::ImexProgress(progress)); *this.last_progress = progress; } } res } fn poll_flush( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { self.project().inner.poll_flush(cx) } fn poll_shutdown( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { self.project().inner.poll_shutdown(cx) } } /// Exports the database and blobs into a stream. pub(crate) async fn export_backup_stream<'a, W>( context: &'a Context, temp_db_path: &Path, blobdir: BlobDirContents<'a>, writer: W, file_size: u64, ) -> Result<()> where W: tokio::io::AsyncWrite + tokio::io::AsyncWriteExt + Unpin + Send + 'static, { let writer = ProgressWriter::new(writer, context.clone(), file_size); let mut builder = tokio_tar::Builder::new(writer); builder .append_path_with_name(temp_db_path, DBFILE_BACKUP_NAME) .await?; for blob in blobdir.iter() { let mut file = File::open(blob.to_abs_path()).await?; let path_in_archive = PathBuf::from(BLOBS_BACKUP_NAME).join(blob.as_name()); builder.append_file(path_in_archive, &mut file).await?; } builder.finish().await?; Ok(()) } /// Imports secret key from a file. async fn import_secret_key(context: &Context, path: &Path) -> Result<()> { let buf = read_file(context, path).await?; let armored = std::string::String::from_utf8_lossy(&buf); set_self_key(context, &armored).await?; Ok(()) } /// Imports secret keys from the provided file or directory. /// /// If provided path is a file, ASCII-armored secret key is read from the file /// and set as the default key. /// /// If provided path is a directory, all files with .asc extension /// containing secret keys are imported and the last successfully /// imported which does not contain "legacy" in its filename /// is set as the default. #[expect(clippy::arithmetic_side_effects)] async fn import_self_keys(context: &Context, path: &Path) -> Result<()> { let attr = tokio::fs::metadata(path).await?; if attr.is_file() { info!( context, "Importing secret key from {} as the default key.", path.display() ); import_secret_key(context, path).await?; return Ok(()); } let mut imported_cnt = 0; let mut dir_handle = tokio::fs::read_dir(&path).await?; while let Ok(Some(entry)) = dir_handle.next_entry().await { let entry_fn = entry.file_name(); let name_f = entry_fn.to_string_lossy(); let path_plus_name = path.join(&entry_fn); if let Some(suffix) = get_filesuffix_lc(&name_f) { if suffix != "asc" { continue; } } else { continue; }; info!( context, "Considering key file: {}.", path_plus_name.display() ); if let Err(err) = import_secret_key(context, &path_plus_name).await { warn!( context, "Failed to import secret key from {}: {:#}.", path_plus_name.display(), err ); continue; } imported_cnt += 1; } ensure!( imported_cnt > 0, "No private keys found in {}.", path.display() ); Ok(()) } #[expect(clippy::arithmetic_side_effects)] async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> { let mut export_errors = 0; let keys = context .sql .query_map_vec( "SELECT id, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;", (), |row| { let id = row.get(0)?; let private_key_blob: Vec = row.get(1)?; let private_key = SignedSecretKey::from_slice(&private_key_blob); let is_default: i32 = row.get(2)?; Ok((id, private_key, is_default)) }, ) .await?; let self_addr = context.get_primary_self_addr().await?; for (id, private_key, is_default) in keys { let id = Some(id).filter(|_| is_default == 0); let Ok(private_key) = private_key else { export_errors += 1; continue; }; if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &private_key).await { error!(context, "Failed to export private key: {:#}.", err); export_errors += 1; } let public_key = private_key.to_public_key(); if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &public_key).await { error!(context, "Failed to export public key: {:#}.", err); export_errors += 1; } } ensure!(export_errors == 0, "errors while exporting keys"); Ok(()) } /// Returns the exported key file name inside `dir`. async fn export_key_to_asc_file( context: &Context, dir: &Path, addr: &str, id: Option, key: &T, ) -> Result where T: DcKey, { let file_name = { let kind = match T::is_private() { false => "public", true => "private", }; let id = id.map_or("default".into(), |i| i.to_string()); let fp = key.dc_fingerprint().hex(); format!("{kind}-key-{addr}-{id}-{fp}.asc") }; let path = dir.join(&file_name); info!(context, "Exporting key to {}.", path.display()); // Delete the file if it already exists. delete_file(context, &path).await.ok(); let content = key.to_asc(None).into_bytes(); write_file(context, &path, &content) .await .with_context(|| format!("cannot write key to {}", path.display()))?; context.emit_event(EventType::ImexFileWritten(path)); Ok(file_name) } /// Exports the database to *dest*, encrypted using *passphrase*. /// /// The directory of *dest* must already exist, if *dest* itself exists it will be /// overwritten. /// /// This also verifies that IO is not running during the export. async fn export_database( context: &Context, dest: &Path, passphrase: String, timestamp: i64, ) -> Result<()> { ensure!( !context.scheduler.is_running().await, "cannot export backup, IO is running" ); let timestamp = timestamp.try_into().context("32-bit UNIX time overflow")?; // TODO: Maybe introduce camino crate for UTF-8 paths where we need them. let dest = dest .to_str() .with_context(|| format!("path {} is not valid unicode", dest.display()))?; context.set_config(Config::BccSelf, Some("1")).await?; context .sql .set_raw_config_int("backup_time", timestamp) .await?; context .sql .set_raw_config_int("backup_version", DCBACKUP_VERSION) .await?; sql::housekeeping(context).await.log_err(context).ok(); context .sql .call_write(|conn| { conn.execute("VACUUM;", ()) .map_err(|err| warn!(context, "Vacuum failed, exporting anyway {err}")) .ok(); conn.execute("ATTACH DATABASE ? AS backup KEY ?", (dest, passphrase)) .context("failed to attach backup database")?; let res = conn .query_row("SELECT sqlcipher_export('backup')", [], |_row| Ok(())) .context("failed to export to attached backup database"); conn.execute( "UPDATE backup.config SET value='0' WHERE keyname='verified_one_on_one_chats';", [], ) .ok(); // Deprecated 2025-07. If verified_one_on_one_chats was not set, this errors, which we ignore conn.execute("DETACH DATABASE backup", []) .context("failed to detach backup database")?; res?; Ok(()) }) .await } async fn check_backup_version(context: &Context) -> Result<()> { let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2); ensure!( version <= DCBACKUP_VERSION, "This profile is from a newer version of Delta Chat. Please update Delta Chat and try again (profile version is v{version}, the latest supported is v{DCBACKUP_VERSION})" ); Ok(()) } #[cfg(test)] mod tests { use std::time::Duration; use tokio::task; use super::*; use crate::config::Config; use crate::test_utils::{TestContext, TestContextManager, alice_keypair}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_export_public_key_to_asc_file() { let context = TestContext::new().await; let key = alice_keypair().to_public_key(); let blobdir = Path::new("$BLOBDIR"); let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key) .await .unwrap(); assert!(filename.starts_with("public-key-a@b-default-")); assert!(filename.ends_with(".asc")); let blobdir = context.ctx.get_blobdir().to_str().unwrap(); let filename = format!("{blobdir}/{filename}"); let bytes = tokio::fs::read(&filename).await.unwrap(); assert_eq!(bytes, key.to_asc(None).into_bytes()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_import_private_key_exported_to_asc_file() { let context = TestContext::new().await; let key = alice_keypair(); let blobdir = Path::new("$BLOBDIR"); let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key) .await .unwrap(); let fingerprint = filename .strip_prefix("private-key-a@b-default-") .unwrap() .strip_suffix(".asc") .unwrap(); assert_eq!(fingerprint, key.dc_fingerprint().hex()); let blobdir = context.ctx.get_blobdir().to_str().unwrap(); let filename = format!("{blobdir}/{filename}"); let bytes = tokio::fs::read(&filename).await.unwrap(); assert_eq!(bytes, key.to_asc(None).into_bytes()); let alice = &TestContext::new().await; if let Err(err) = imex(alice, ImexMode::ImportSelfKeys, Path::new(&filename), None).await { panic!("got error on import: {err:#}"); } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_export_and_import_key_from_dir() { let export_dir = tempfile::tempdir().unwrap(); let context = TestContext::new_alice().await; if let Err(err) = imex( &context.ctx, ImexMode::ExportSelfKeys, export_dir.path(), None, ) .await { panic!("got error on export: {err:#}"); } let context2 = TestContext::new().await; if let Err(err) = imex( &context2.ctx, ImexMode::ImportSelfKeys, export_dir.path(), None, ) .await { panic!("got error on import: {err:#}"); } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_import_second_key() -> Result<()> { let alice = &TestContext::new_alice().await; let chat = alice.create_chat(alice).await; let sent = alice.send_text(chat.id, "Encrypted with old key").await; let export_dir = tempfile::tempdir().unwrap(); let alice = &TestContext::new().await; alice.configure_addr("alice@example.org").await; imex(alice, ImexMode::ExportSelfKeys, export_dir.path(), None).await?; let alice = &TestContext::new_alice().await; let old_key = key::load_self_secret_key(alice).await?; assert!( imex(alice, ImexMode::ImportSelfKeys, export_dir.path(), None) .await .is_err() ); // Importing a second key is not allowed anymore, // even as a non-default key. assert_eq!(key::load_self_secret_key(alice).await?, old_key); assert_eq!(key::load_self_secret_keyring(alice).await?, vec![old_key]); let msg = alice.recv_msg(&sent).await; assert!(msg.get_showpadlock()); assert_eq!(msg.chat_id, alice.get_self_chat().await.id); assert_eq!(msg.get_text(), "Encrypted with old key"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_export_and_import_backup() -> Result<()> { let backup_dir = tempfile::tempdir().unwrap(); let context1 = TestContext::new_alice().await; assert!(context1.is_configured().await?); let context2 = TestContext::new().await; assert!(!context2.is_configured().await?); assert!(has_backup(&context2, backup_dir.path()).await.is_err()); // export from context1 assert!( imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None) .await .is_ok() ); let _event = context1 .evtracker .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) .await; // import to context2 let backup = has_backup(&context2, backup_dir.path()).await?; // Import of unencrypted backup with incorrect "foobar" backup passphrase fails. assert!( imex( &context2, ImexMode::ImportBackup, backup.as_ref(), Some("foobar".to_string()) ) .await .is_err() ); assert!( imex(&context2, ImexMode::ImportBackup, backup.as_ref(), None) .await .is_ok() ); let _event = context2 .evtracker .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) .await; assert!(context2.is_configured().await?); assert_eq!( context2.get_config(Config::Addr).await?, Some("alice@example.org".to_string()) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_export_import_chatmail_backup() -> Result<()> { let backup_dir = tempfile::tempdir().unwrap(); let context1 = &TestContext::new_alice().await; // `bcc_self` is enabled by default for test contexts. Unset it. context1.set_config(Config::BccSelf, None).await?; // Check that the settings are displayed correctly. assert_eq!( context1.get_config(Config::DeleteServerAfter).await?, Some("0".to_string()) ); context1.set_config_bool(Config::IsChatmail, true).await?; assert_eq!( context1.get_config(Config::BccSelf).await?, Some("0".to_string()) ); assert_eq!( context1.get_config(Config::DeleteServerAfter).await?, Some("1".to_string()) ); assert_eq!(context1.get_config_delete_server_after().await?, Some(0)); imex(context1, ImexMode::ExportBackup, backup_dir.path(), None).await?; let _event = context1 .evtracker .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) .await; let context2 = &TestContext::new().await; let backup = has_backup(context2, backup_dir.path()).await?; imex(context2, ImexMode::ImportBackup, backup.as_ref(), None).await?; let _event = context2 .evtracker .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) .await; assert!(context2.is_configured().await?); assert!(context2.is_chatmail().await?); for ctx in [context1, context2] { assert_eq!( ctx.get_config(Config::BccSelf).await?, Some("1".to_string()) ); assert_eq!( ctx.get_config(Config::DeleteServerAfter).await?, Some("0".to_string()) ); assert_eq!(ctx.get_config_delete_server_after().await?, None); } Ok(()) } /// Tests that [`crate::qr::DCBACKUP_VERSION`] is checked correctly. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_import_backup_fails_because_of_dcbackup_version() -> Result<()> { let mut tcm = TestContextManager::new(); let context1 = tcm.alice().await; let context2 = tcm.unconfigured().await; assert!(context1.is_configured().await?); assert!(!context2.is_configured().await?); let backup_dir = tempfile::tempdir().unwrap(); tcm.section("export from context1"); assert!( imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None) .await .is_ok() ); let _event = context1 .evtracker .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) .await; let backup = has_backup(&context2, backup_dir.path()).await?; let modified_backup = backup_dir.path().join("modified_backup.tar"); tcm.section("Change backup_version to be higher than DCBACKUP_VERSION"); { let unpack_dir = tempfile::tempdir().unwrap(); let mut ar = Archive::new(File::open(&backup).await?); ar.unpack(&unpack_dir).await?; let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME)); sql.open(&context2, "".to_string()).await?; assert_eq!( sql.get_raw_config_int("backup_version").await?.unwrap(), DCBACKUP_VERSION ); sql.set_raw_config_int("backup_version", DCBACKUP_VERSION + 1) .await?; sql.close().await; let modified_backup_file = File::create(&modified_backup).await?; let mut builder = tokio_tar::Builder::new(modified_backup_file); builder.append_dir_all("", unpack_dir.path()).await?; builder.finish().await?; } tcm.section("import to context2"); let err = imex(&context2, ImexMode::ImportBackup, &modified_backup, None) .await .unwrap_err(); assert!(err.to_string().starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again")); // Some UIs show the error from the event to the user. // Therefore, it must also be a user-facing string, rather than some technical info: let err_event = context2 .evtracker .get_matching(|evt| matches!(evt, EventType::Error(_))) .await; let EventType::Error(err_msg) = err_event else { unreachable!() }; assert!(err_msg.starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again")); context2 .evtracker .get_matching(|evt| matches!(evt, EventType::ImexProgress(0))) .await; assert!(!context2.is_configured().await?); assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None); Ok(()) } /// This is a regression test for /// https://github.com/deltachat/deltachat-android/issues/2263 /// where the config cache wasn't reset properly after a backup. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_import_backup_reset_config_cache() -> Result<()> { let backup_dir = tempfile::tempdir()?; let context1 = TestContext::new_alice().await; let context2 = TestContext::new().await; assert!(!context2.is_configured().await?); // export from context1 imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None).await?; // import to context2 let backup = has_backup(&context2, backup_dir.path()).await?; let context2_cloned = context2.clone(); let handle = task::spawn(async move { imex( &context2_cloned, ImexMode::ImportBackup, backup.as_ref(), None, ) .await .unwrap(); }); while !handle.is_finished() { // The database is still unconfigured; // fill the config cache with the old value. context2.is_configured().await.ok(); tokio::time::sleep(Duration::from_micros(1)).await; } // Assert that the config cache has the new value now. assert!(context2.is_configured().await?); Ok(()) } /// Tests importing a backup from Delta Chat 1.30.3 for Android (core v1.86.0). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_import_ancient_backup() -> Result<()> { let mut tcm = TestContextManager::new(); let context = &tcm.unconfigured().await; let backup_path = Path::new("test-data/core-1.86.0-backup.tar"); imex(context, ImexMode::ImportBackup, backup_path, None).await?; Ok(()) } }