Skip to main content

deltachat/
imex.rs

1//! # Import/export module.
2
3use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6
7use anyhow::{Context as _, Result, bail, ensure, format_err};
8use futures::TryStreamExt;
9use futures_lite::FutureExt;
10use pin_project::pin_project;
11
12use tokio::fs::{self, File};
13use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
14use tokio_tar::Archive;
15
16use crate::blob::BlobDirContents;
17use crate::chat::delete_and_reset_all_device_msgs;
18use crate::config::Config;
19use crate::context::Context;
20use crate::events::EventType;
21use crate::key::{self, DcKey, SignedSecretKey};
22use crate::log::{LogExt, warn};
23use crate::qr::DCBACKUP_VERSION;
24use crate::sql;
25use crate::tools::{
26    TempPathGuard, create_folder, delete_file, get_filesuffix_lc, read_file, time, usize_to_u64,
27    write_file,
28};
29
30mod transfer;
31
32use ::pgp::types::KeyDetails;
33pub use transfer::{BackupProvider, get_backup};
34
35// Name of the database file in the backup.
36const DBFILE_BACKUP_NAME: &str = "dc_database_backup.sqlite";
37pub(crate) const BLOBS_BACKUP_NAME: &str = "blobs_backup";
38
39/// Import/export command.
40#[derive(Debug, Display, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)]
41#[repr(u32)]
42pub enum ImexMode {
43    /// Export all private keys and all public keys of the user to the
44    /// directory given as `path`. The default key is written to the files
45    /// `{public,private}-key-<addr>-default-<fingerprint>.asc`, if there are more keys, they are
46    /// written to files as `{public,private}-key-<addr>-<id>-<fingerprint>.asc`.
47    ExportSelfKeys = 1,
48
49    /// Import private keys found in `path` if it is a directory, otherwise import a private key
50    /// from `path`.
51    /// The last imported key is made the default keys unless its name contains the string `legacy`.
52    /// Public keys are not imported.
53    ImportSelfKeys = 2,
54
55    /// Export a backup to the directory given as `path` with the given `passphrase`.
56    /// The backup contains all contacts, chats, images and other data and device independent settings.
57    /// The backup does not contain device dependent settings as ringtones or LED notification settings.
58    /// The name of the backup is `delta-chat-backup-<day>-<number>-<addr>.tar`.
59    ExportBackup = 11,
60
61    /// `path` is the file (not: directory) to import. The file is normally
62    /// created by DC_IMEX_EXPORT_BACKUP and detected by imex_has_backup(). Importing a backup
63    /// is only possible as long as the context is not configured or used in another way.
64    ImportBackup = 12,
65}
66
67/// Import/export things.
68///
69/// What to do is defined by the `what` parameter.
70///
71/// During execution of the job,
72/// some events are sent out:
73///
74/// - A number of `DC_EVENT_IMEX_PROGRESS` events are sent and may be used to create
75///   a progress bar or stuff like that. Moreover, you'll be informed when the imex-job is done.
76///
77/// - For each file written on export, the function sends `DC_EVENT_IMEX_FILE_WRITTEN`
78///
79/// Only one import-/export-progress can run at the same time.
80/// To cancel an import-/export-progress, drop the future returned by this function.
81pub async fn imex(
82    context: &Context,
83    what: ImexMode,
84    path: &Path,
85    passphrase: Option<String>,
86) -> Result<()> {
87    let cancel = context.alloc_ongoing().await?;
88
89    let res = {
90        let _guard = context.scheduler.pause(context).await?;
91        imex_inner(context, what, path, passphrase)
92            .race(async {
93                cancel.recv().await.ok();
94                Err(format_err!("canceled"))
95            })
96            .await
97    };
98    context.free_ongoing().await;
99
100    if let Err(err) = res.as_ref() {
101        // We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
102        error!(context, "{:#}", err);
103        warn!(context, "IMEX failed to complete: {:#}", err);
104        context.emit_event(EventType::ImexProgress(0));
105    } else {
106        info!(context, "IMEX successfully completed");
107        context.emit_event(EventType::ImexProgress(1000));
108    }
109
110    res
111}
112
113/// Returns the filename of the backup found (otherwise an error)
114pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
115    let mut dir_iter = tokio::fs::read_dir(dir_name).await?;
116    let mut newest_backup_name = "".to_string();
117    let mut newest_backup_path: Option<PathBuf> = None;
118
119    while let Ok(Some(dirent)) = dir_iter.next_entry().await {
120        let path = dirent.path();
121        let name = dirent.file_name();
122        let name: String = name.to_string_lossy().into();
123        if name.starts_with("delta-chat")
124            && name.ends_with(".tar")
125            && (newest_backup_name.is_empty() || name > newest_backup_name)
126        {
127            // We just use string comparison to determine which backup is newer.
128            // This works fine because the filenames have the form `delta-chat-backup-2023-10-18-00-foo@example.com.tar`
129            newest_backup_path = Some(path);
130            newest_backup_name = name;
131        }
132    }
133
134    match newest_backup_path {
135        Some(path) => Ok(path.to_string_lossy().into_owned()),
136        None => bail!("no backup found in {}", dir_name.display()),
137    }
138}
139
140async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
141    let secret_key = SignedSecretKey::from_asc(armored)?;
142    key::store_self_keypair(context, &secret_key).await?;
143
144    info!(
145        context,
146        "Stored self key: {:?}.",
147        secret_key.public_key().fingerprint()
148    );
149    Ok(())
150}
151
152async fn imex_inner(
153    context: &Context,
154    what: ImexMode,
155    path: &Path,
156    passphrase: Option<String>,
157) -> Result<()> {
158    info!(
159        context,
160        "{} path: {}",
161        match what {
162            ImexMode::ExportSelfKeys | ImexMode::ExportBackup => "Export",
163            ImexMode::ImportSelfKeys | ImexMode::ImportBackup => "Import",
164        },
165        path.display()
166    );
167    ensure!(context.sql.is_open().await, "Database not opened.");
168    context.emit_event(EventType::ImexProgress(1));
169
170    if what == ImexMode::ExportBackup || what == ImexMode::ExportSelfKeys {
171        // before we export anything, make sure the private key exists
172        key::ensure_secret_key_exists(context)
173            .await
174            .context("Cannot create private key or private key not available")?;
175
176        create_folder(context, path).await?;
177    }
178
179    match what {
180        ImexMode::ExportSelfKeys => export_self_keys(context, path).await,
181        ImexMode::ImportSelfKeys => import_self_keys(context, path).await,
182
183        ImexMode::ExportBackup => {
184            export_backup(context, path, passphrase.unwrap_or_default()).await
185        }
186        ImexMode::ImportBackup => {
187            import_backup(context, path, passphrase.unwrap_or_default()).await
188        }
189    }
190}
191
192/// Imports backup into the currently open database.
193///
194/// The contents of the currently open database will be lost.
195///
196/// `passphrase` is the passphrase used to open backup database. If backup is unencrypted, pass
197/// empty string here.
198async fn import_backup(
199    context: &Context,
200    backup_to_import: &Path,
201    passphrase: String,
202) -> Result<()> {
203    let backup_file = File::open(backup_to_import).await?;
204    let file_size = backup_file.metadata().await?.len();
205    info!(
206        context,
207        "Import \"{}\" ({} bytes) to \"{}\".",
208        backup_to_import.display(),
209        file_size,
210        context.get_dbfile().display()
211    );
212
213    import_backup_stream(context, backup_file, file_size, passphrase).await?;
214    Ok(())
215}
216
217/// Imports backup by reading a tar file from a stream.
218///
219/// `file_size` is used to calculate the progress
220/// and emit progress events.
221/// Ideally it is the sum of the entry
222/// sizes without the header overhead,
223/// but can be estimated as tar file size
224/// in which case the progress is underestimated
225/// and may not reach 99.9% by the end of import.
226/// Underestimating is better than
227/// overestimating because the progress
228/// jumps to 100% instead of getting stuck at 99.9%
229/// for some time.
230pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
231    context: &Context,
232    backup_file: R,
233    file_size: u64,
234    passphrase: String,
235) -> Result<()> {
236    ensure!(
237        !context.is_configured().await?,
238        "Cannot import backups to accounts in use"
239    );
240    ensure!(
241        !context.scheduler.is_running().await,
242        "Cannot import backup, IO is running"
243    );
244
245    import_backup_stream_inner(context, backup_file, file_size, passphrase)
246        .await
247        .0
248}
249
250/// Reader that emits progress events as bytes are read from it.
251#[pin_project]
252struct ProgressReader<R> {
253    /// Wrapped reader.
254    #[pin]
255    inner: R,
256
257    /// Number of bytes successfully read from the internal reader.
258    read: u64,
259
260    /// Total size of the backup .tar file expected to be read from the reader.
261    /// Used to calculate the progress.
262    file_size: u64,
263
264    /// Last progress emitted to avoid emitting the same progress value twice.
265    last_progress: u16,
266
267    /// Context for emitting progress events.
268    context: Context,
269}
270
271impl<R> ProgressReader<R> {
272    fn new(r: R, context: Context, file_size: u64) -> Self {
273        Self {
274            inner: r,
275            read: 0,
276            file_size,
277            last_progress: 1,
278            context,
279        }
280    }
281}
282
283impl<R> AsyncRead for ProgressReader<R>
284where
285    R: AsyncRead,
286{
287    #[expect(clippy::arithmetic_side_effects)]
288    fn poll_read(
289        self: Pin<&mut Self>,
290        cx: &mut std::task::Context<'_>,
291        buf: &mut ReadBuf<'_>,
292    ) -> std::task::Poll<std::io::Result<()>> {
293        let this = self.project();
294        let before = buf.filled().len();
295        let res = this.inner.poll_read(cx, buf);
296        if let std::task::Poll::Ready(Ok(())) = res {
297            *this.read = this
298                .read
299                .saturating_add(usize_to_u64(buf.filled().len() - before));
300
301            let progress = std::cmp::min(1000 * *this.read / *this.file_size, 999) as u16;
302            if progress > *this.last_progress {
303                this.context.emit_event(EventType::ImexProgress(progress));
304                *this.last_progress = progress;
305            }
306        }
307        res
308    }
309}
310
311// This function returns a tuple (Result<()>,) rather than Result<()>
312// so that we don't accidentally early-return with `?`
313// and forget to cleanup.
314async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
315    context: &Context,
316    backup_file: R,
317    file_size: u64,
318    passphrase: String,
319) -> (Result<()>,) {
320    let backup_file = ProgressReader::new(backup_file, context.clone(), file_size);
321    let mut archive = Archive::new(backup_file);
322
323    let mut entries = match archive.entries() {
324        Ok(entries) => entries,
325        Err(e) => return (Err(e).context("Failed to get archive entries"),),
326    };
327    let mut blobs = Vec::new();
328    let mut res: Result<()> = loop {
329        let mut f = match entries.try_next().await {
330            Ok(Some(f)) => f,
331            Ok(None) => break Ok(()),
332            Err(e) => break Err(e).context("Failed to get next entry"),
333        };
334
335        let path = match f.path() {
336            Ok(path) => path.to_path_buf(),
337            Err(e) => break Err(e).context("Failed to get entry path"),
338        };
339        if let Err(e) = f.unpack_in(context.get_blobdir()).await {
340            break Err(e).context("Failed to unpack file");
341        }
342        if path.file_name() == Some(OsStr::new(DBFILE_BACKUP_NAME)) {
343            continue;
344        }
345        // async_tar unpacked to $BLOBDIR/BLOBS_BACKUP_NAME/, so we move the file afterwards.
346        let from_path = context.get_blobdir().join(&path);
347        if from_path.is_file() {
348            if let Some(name) = from_path.file_name() {
349                let to_path = context.get_blobdir().join(name);
350                if let Err(e) = fs::rename(&from_path, &to_path).await {
351                    blobs.push(from_path);
352                    break Err(e).context("Failed to move file to blobdir");
353                }
354                blobs.push(to_path);
355            } else {
356                warn!(context, "No file name");
357            }
358        }
359    };
360
361    let unpacked_database = context.get_blobdir().join(DBFILE_BACKUP_NAME);
362    if res.is_ok() {
363        res = context
364            .sql
365            .import(&unpacked_database, passphrase.clone())
366            .await
367            .context("cannot import unpacked database");
368    }
369    if res.is_ok() {
370        res = check_backup_version(context).await;
371    }
372    fs::remove_file(unpacked_database)
373        .await
374        .context("cannot remove unpacked database")
375        .log_err(context)
376        .ok();
377    if res.is_ok() {
378        context.emit_event(EventType::ImexProgress(999));
379        res = context.sql.run_migrations(context).await;
380        context.emit_event(EventType::AccountsItemChanged);
381    }
382    if res.is_err() {
383        context.sql.close().await;
384        fs::remove_file(context.sql.dbfile.as_path())
385            .await
386            .log_err(context)
387            .ok();
388        for blob in blobs {
389            fs::remove_file(&blob).await.log_err(context).ok();
390        }
391        context
392            .sql
393            .open(context, "".to_string())
394            .await
395            .log_err(context)
396            .ok();
397    }
398    if res.is_ok() {
399        delete_and_reset_all_device_msgs(context)
400            .await
401            .log_err(context)
402            .ok();
403    }
404    (res,)
405}
406
407/*******************************************************************************
408 * Export backup
409 ******************************************************************************/
410
411/// Returns Ok((temp_db_path, temp_path, dest_path)) on success. Unencrypted database can be
412/// written to temp_db_path. The backup can then be written to temp_path. If the backup succeeded,
413/// it can be renamed to dest_path. This guarantees that the backup is complete.
414fn get_next_backup_path(
415    folder: &Path,
416    addr: &str,
417    backup_time: i64,
418) -> Result<(PathBuf, PathBuf, PathBuf)> {
419    let folder = PathBuf::from(folder);
420    let stem = chrono::DateTime::<chrono::Utc>::from_timestamp(backup_time, 0)
421        .context("can't get next backup path")?
422        // Don't change this file name format, in `dc_imex_has_backup` we use string comparison to determine which backup is newer:
423        .format("delta-chat-backup-%Y-%m-%d")
424        .to_string();
425
426    // 64 backup files per day should be enough for everyone
427    for i in 0..64 {
428        let mut tempdbfile = folder.clone();
429        tempdbfile.push(format!("{stem}-{i:02}-{addr}.db"));
430
431        let mut tempfile = folder.clone();
432        tempfile.push(format!("{stem}-{i:02}-{addr}.tar.part"));
433
434        let mut destfile = folder.clone();
435        destfile.push(format!("{stem}-{i:02}-{addr}.tar"));
436
437        if !tempdbfile.exists() && !tempfile.exists() && !destfile.exists() {
438            return Ok((tempdbfile, tempfile, destfile));
439        }
440    }
441    bail!("could not create backup file, disk full?");
442}
443
444/// Exports the database to a separate file with the given passphrase.
445///
446/// Set passphrase to empty string to export the database unencrypted.
447#[expect(clippy::arithmetic_side_effects)]
448async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Result<()> {
449    // get a fine backup file name (the name includes the date so that multiple backup instances are possible)
450    let now = time();
451    let self_addr = context.get_primary_self_addr().await?;
452    let (temp_db_path, temp_path, dest_path) = get_next_backup_path(dir, &self_addr, now)?;
453    let temp_db_path = TempPathGuard::new(temp_db_path);
454    let temp_path = TempPathGuard::new(temp_path);
455
456    export_database(context, &temp_db_path, passphrase, now)
457        .await
458        .context("could not export database")?;
459
460    info!(
461        context,
462        "Backup '{}' to '{}'.",
463        context.get_dbfile().display(),
464        dest_path.display(),
465    );
466
467    let file = File::create(&temp_path).await?;
468    let blobdir = BlobDirContents::new(context).await?;
469
470    let mut file_size = 0;
471    file_size += temp_db_path.metadata()?.len();
472    for blob in blobdir.iter() {
473        file_size += blob.to_abs_path().metadata()?.len()
474    }
475
476    export_backup_stream(context, &temp_db_path, blobdir, file, file_size)
477        .await
478        .context("Exporting backup to file failed")?;
479    fs::rename(temp_path, &dest_path).await?;
480    context.emit_event(EventType::ImexFileWritten(dest_path));
481    Ok(())
482}
483
484/// Writer that emits progress events as bytes are written into it.
485#[pin_project]
486struct ProgressWriter<W> {
487    /// Wrapped writer.
488    #[pin]
489    inner: W,
490
491    /// Number of bytes successfully written into the internal writer.
492    written: u64,
493
494    /// Total size of the backup .tar file expected to be written into the writer.
495    /// Used to calculate the progress.
496    file_size: u64,
497
498    /// Last progress emitted to avoid emitting the same progress value twice.
499    last_progress: u16,
500
501    /// Context for emitting progress events.
502    context: Context,
503}
504
505impl<W> ProgressWriter<W> {
506    fn new(w: W, context: Context, file_size: u64) -> Self {
507        Self {
508            inner: w,
509            written: 0,
510            file_size,
511            last_progress: 1,
512            context,
513        }
514    }
515}
516
517impl<W> AsyncWrite for ProgressWriter<W>
518where
519    W: AsyncWrite,
520{
521    #[expect(clippy::arithmetic_side_effects)]
522    fn poll_write(
523        self: Pin<&mut Self>,
524        cx: &mut std::task::Context<'_>,
525        buf: &[u8],
526    ) -> std::task::Poll<Result<usize, std::io::Error>> {
527        let this = self.project();
528        let res = this.inner.poll_write(cx, buf);
529        if let std::task::Poll::Ready(Ok(written)) = res {
530            *this.written = this.written.saturating_add(usize_to_u64(written));
531
532            let progress = std::cmp::min(1000 * *this.written / *this.file_size, 999) as u16;
533            if progress > *this.last_progress {
534                this.context.emit_event(EventType::ImexProgress(progress));
535                *this.last_progress = progress;
536            }
537        }
538        res
539    }
540
541    fn poll_flush(
542        self: Pin<&mut Self>,
543        cx: &mut std::task::Context<'_>,
544    ) -> std::task::Poll<Result<(), std::io::Error>> {
545        self.project().inner.poll_flush(cx)
546    }
547
548    fn poll_shutdown(
549        self: Pin<&mut Self>,
550        cx: &mut std::task::Context<'_>,
551    ) -> std::task::Poll<Result<(), std::io::Error>> {
552        self.project().inner.poll_shutdown(cx)
553    }
554}
555
556/// Exports the database and blobs into a stream.
557pub(crate) async fn export_backup_stream<'a, W>(
558    context: &'a Context,
559    temp_db_path: &Path,
560    blobdir: BlobDirContents<'a>,
561    writer: W,
562    file_size: u64,
563) -> Result<()>
564where
565    W: tokio::io::AsyncWrite + tokio::io::AsyncWriteExt + Unpin + Send + 'static,
566{
567    let writer = ProgressWriter::new(writer, context.clone(), file_size);
568    let mut builder = tokio_tar::Builder::new(writer);
569
570    builder
571        .append_path_with_name(temp_db_path, DBFILE_BACKUP_NAME)
572        .await?;
573
574    for blob in blobdir.iter() {
575        let mut file = File::open(blob.to_abs_path()).await?;
576        let path_in_archive = PathBuf::from(BLOBS_BACKUP_NAME).join(blob.as_name());
577        builder.append_file(path_in_archive, &mut file).await?;
578    }
579
580    builder.finish().await?;
581    Ok(())
582}
583
584/// Imports secret key from a file.
585async fn import_secret_key(context: &Context, path: &Path) -> Result<()> {
586    let buf = read_file(context, path).await?;
587    let armored = std::string::String::from_utf8_lossy(&buf);
588    set_self_key(context, &armored).await?;
589    Ok(())
590}
591
592/// Imports secret keys from the provided file or directory.
593///
594/// If provided path is a file, ASCII-armored secret key is read from the file
595/// and set as the default key.
596///
597/// If provided path is a directory, all files with .asc extension
598/// containing secret keys are imported and the last successfully
599/// imported which does not contain "legacy" in its filename
600/// is set as the default.
601#[expect(clippy::arithmetic_side_effects)]
602async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
603    let attr = tokio::fs::metadata(path).await?;
604
605    if attr.is_file() {
606        info!(
607            context,
608            "Importing secret key from {} as the default key.",
609            path.display()
610        );
611        import_secret_key(context, path).await?;
612        return Ok(());
613    }
614
615    let mut imported_cnt = 0;
616
617    let mut dir_handle = tokio::fs::read_dir(&path).await?;
618    while let Ok(Some(entry)) = dir_handle.next_entry().await {
619        let entry_fn = entry.file_name();
620        let name_f = entry_fn.to_string_lossy();
621        let path_plus_name = path.join(&entry_fn);
622        if let Some(suffix) = get_filesuffix_lc(&name_f) {
623            if suffix != "asc" {
624                continue;
625            }
626        } else {
627            continue;
628        };
629        info!(
630            context,
631            "Considering key file: {}.",
632            path_plus_name.display()
633        );
634
635        if let Err(err) = import_secret_key(context, &path_plus_name).await {
636            warn!(
637                context,
638                "Failed to import secret key from {}: {:#}.",
639                path_plus_name.display(),
640                err
641            );
642            continue;
643        }
644
645        imported_cnt += 1;
646    }
647    ensure!(
648        imported_cnt > 0,
649        "No private keys found in {}.",
650        path.display()
651    );
652    Ok(())
653}
654
655#[expect(clippy::arithmetic_side_effects)]
656async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
657    let mut export_errors = 0;
658
659    let keys = context
660        .sql
661        .query_map_vec(
662            "SELECT id, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
663            (),
664            |row| {
665                let id = row.get(0)?;
666                let private_key_blob: Vec<u8> = row.get(1)?;
667                let private_key = SignedSecretKey::from_slice(&private_key_blob);
668                let is_default: i32 = row.get(2)?;
669
670                Ok((id, private_key, is_default))
671            },
672        )
673        .await?;
674    let self_addr = context.get_primary_self_addr().await?;
675    for (id, private_key, is_default) in keys {
676        let id = Some(id).filter(|_| is_default == 0);
677
678        let Ok(private_key) = private_key else {
679            export_errors += 1;
680            continue;
681        };
682
683        if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &private_key).await {
684            error!(context, "Failed to export private key: {:#}.", err);
685            export_errors += 1;
686        }
687
688        let public_key = private_key.to_public_key();
689
690        if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &public_key).await {
691            error!(context, "Failed to export public key: {:#}.", err);
692            export_errors += 1;
693        }
694    }
695
696    ensure!(export_errors == 0, "errors while exporting keys");
697    Ok(())
698}
699
700/// Returns the exported key file name inside `dir`.
701async fn export_key_to_asc_file<T>(
702    context: &Context,
703    dir: &Path,
704    addr: &str,
705    id: Option<i64>,
706    key: &T,
707) -> Result<String>
708where
709    T: DcKey,
710{
711    let file_name = {
712        let kind = match T::is_private() {
713            false => "public",
714            true => "private",
715        };
716        let id = id.map_or("default".into(), |i| i.to_string());
717        let fp = key.dc_fingerprint().hex();
718        format!("{kind}-key-{addr}-{id}-{fp}.asc")
719    };
720    let path = dir.join(&file_name);
721    info!(context, "Exporting key to {}.", path.display());
722
723    // Delete the file if it already exists.
724    delete_file(context, &path).await.ok();
725
726    let content = key.to_asc(None).into_bytes();
727    write_file(context, &path, &content)
728        .await
729        .with_context(|| format!("cannot write key to {}", path.display()))?;
730    context.emit_event(EventType::ImexFileWritten(path));
731    Ok(file_name)
732}
733
734/// Exports the database to *dest*, encrypted using *passphrase*.
735///
736/// The directory of *dest* must already exist, if *dest* itself exists it will be
737/// overwritten.
738///
739/// This also verifies that IO is not running during the export.
740async fn export_database(
741    context: &Context,
742    dest: &Path,
743    passphrase: String,
744    timestamp: i64,
745) -> Result<()> {
746    ensure!(
747        !context.scheduler.is_running().await,
748        "cannot export backup, IO is running"
749    );
750    let timestamp = timestamp.try_into().context("32-bit UNIX time overflow")?;
751
752    // TODO: Maybe introduce camino crate for UTF-8 paths where we need them.
753    let dest = dest
754        .to_str()
755        .with_context(|| format!("path {} is not valid unicode", dest.display()))?;
756
757    context.set_config(Config::BccSelf, Some("1")).await?;
758    context
759        .sql
760        .set_raw_config_int("backup_time", timestamp)
761        .await?;
762    context
763        .sql
764        .set_raw_config_int("backup_version", DCBACKUP_VERSION)
765        .await?;
766    sql::housekeeping(context).await.log_err(context).ok();
767    context
768        .sql
769        .call_write(|conn| {
770            conn.execute("VACUUM;", ())
771                .map_err(|err| warn!(context, "Vacuum failed, exporting anyway {err}"))
772                .ok();
773            conn.execute("ATTACH DATABASE ? AS backup KEY ?", (dest, passphrase))
774                .context("failed to attach backup database")?;
775            let res = conn
776                .query_row("SELECT sqlcipher_export('backup')", [], |_row| Ok(()))
777                .context("failed to export to attached backup database");
778            conn.execute(
779                "UPDATE backup.config SET value='0' WHERE keyname='verified_one_on_one_chats';",
780                [],
781            )
782            .ok(); // Deprecated 2025-07. If verified_one_on_one_chats was not set, this errors, which we ignore
783            conn.execute("DETACH DATABASE backup", [])
784                .context("failed to detach backup database")?;
785            res?;
786            Ok(())
787        })
788        .await
789}
790
791async fn check_backup_version(context: &Context) -> Result<()> {
792    let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
793    ensure!(
794        version <= DCBACKUP_VERSION,
795        "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})"
796    );
797    Ok(())
798}
799
800#[cfg(test)]
801mod tests {
802    use std::time::Duration;
803
804    use tokio::task;
805
806    use super::*;
807    use crate::config::Config;
808    use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
809
810    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
811    async fn test_export_public_key_to_asc_file() {
812        let context = TestContext::new().await;
813        let key = alice_keypair().to_public_key();
814        let blobdir = Path::new("$BLOBDIR");
815        let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
816            .await
817            .unwrap();
818        assert!(filename.starts_with("public-key-a@b-default-"));
819        assert!(filename.ends_with(".asc"));
820        let blobdir = context.ctx.get_blobdir().to_str().unwrap();
821        let filename = format!("{blobdir}/{filename}");
822        let bytes = tokio::fs::read(&filename).await.unwrap();
823
824        assert_eq!(bytes, key.to_asc(None).into_bytes());
825    }
826
827    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
828    async fn test_import_private_key_exported_to_asc_file() {
829        let context = TestContext::new().await;
830        let key = alice_keypair();
831        let blobdir = Path::new("$BLOBDIR");
832        let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
833            .await
834            .unwrap();
835        let fingerprint = filename
836            .strip_prefix("private-key-a@b-default-")
837            .unwrap()
838            .strip_suffix(".asc")
839            .unwrap();
840        assert_eq!(fingerprint, key.dc_fingerprint().hex());
841        let blobdir = context.ctx.get_blobdir().to_str().unwrap();
842        let filename = format!("{blobdir}/{filename}");
843        let bytes = tokio::fs::read(&filename).await.unwrap();
844
845        assert_eq!(bytes, key.to_asc(None).into_bytes());
846
847        let alice = &TestContext::new().await;
848        if let Err(err) = imex(alice, ImexMode::ImportSelfKeys, Path::new(&filename), None).await {
849            panic!("got error on import: {err:#}");
850        }
851    }
852
853    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
854    async fn test_export_and_import_key_from_dir() {
855        let export_dir = tempfile::tempdir().unwrap();
856
857        let context = TestContext::new_alice().await;
858        if let Err(err) = imex(
859            &context.ctx,
860            ImexMode::ExportSelfKeys,
861            export_dir.path(),
862            None,
863        )
864        .await
865        {
866            panic!("got error on export: {err:#}");
867        }
868
869        let context2 = TestContext::new().await;
870        if let Err(err) = imex(
871            &context2.ctx,
872            ImexMode::ImportSelfKeys,
873            export_dir.path(),
874            None,
875        )
876        .await
877        {
878            panic!("got error on import: {err:#}");
879        }
880    }
881
882    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
883    async fn test_import_second_key() -> Result<()> {
884        let alice = &TestContext::new_alice().await;
885        let chat = alice.create_chat(alice).await;
886        let sent = alice.send_text(chat.id, "Encrypted with old key").await;
887        let export_dir = tempfile::tempdir().unwrap();
888
889        let alice = &TestContext::new().await;
890        alice.configure_addr("alice@example.org").await;
891        imex(alice, ImexMode::ExportSelfKeys, export_dir.path(), None).await?;
892
893        let alice = &TestContext::new_alice().await;
894        let old_key = key::load_self_secret_key(alice).await?;
895
896        assert!(
897            imex(alice, ImexMode::ImportSelfKeys, export_dir.path(), None)
898                .await
899                .is_err()
900        );
901
902        // Importing a second key is not allowed anymore,
903        // even as a non-default key.
904        assert_eq!(key::load_self_secret_key(alice).await?, old_key);
905
906        assert_eq!(key::load_self_secret_keyring(alice).await?, vec![old_key]);
907
908        let msg = alice.recv_msg(&sent).await;
909        assert!(msg.get_showpadlock());
910        assert_eq!(msg.chat_id, alice.get_self_chat().await.id);
911        assert_eq!(msg.get_text(), "Encrypted with old key");
912
913        Ok(())
914    }
915
916    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
917    async fn test_export_and_import_backup() -> Result<()> {
918        let backup_dir = tempfile::tempdir().unwrap();
919
920        let context1 = TestContext::new_alice().await;
921        assert!(context1.is_configured().await?);
922
923        let context2 = TestContext::new().await;
924        assert!(!context2.is_configured().await?);
925        assert!(has_backup(&context2, backup_dir.path()).await.is_err());
926
927        // export from context1
928        assert!(
929            imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
930                .await
931                .is_ok()
932        );
933        let _event = context1
934            .evtracker
935            .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
936            .await;
937
938        // import to context2
939        let backup = has_backup(&context2, backup_dir.path()).await?;
940
941        // Import of unencrypted backup with incorrect "foobar" backup passphrase fails.
942        assert!(
943            imex(
944                &context2,
945                ImexMode::ImportBackup,
946                backup.as_ref(),
947                Some("foobar".to_string())
948            )
949            .await
950            .is_err()
951        );
952
953        assert!(
954            imex(&context2, ImexMode::ImportBackup, backup.as_ref(), None)
955                .await
956                .is_ok()
957        );
958        let _event = context2
959            .evtracker
960            .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
961            .await;
962
963        assert!(context2.is_configured().await?);
964        assert_eq!(
965            context2.get_config(Config::Addr).await?,
966            Some("alice@example.org".to_string())
967        );
968        Ok(())
969    }
970
971    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
972    async fn test_export_import_chatmail_backup() -> Result<()> {
973        let backup_dir = tempfile::tempdir().unwrap();
974
975        let context1 = &TestContext::new_alice().await;
976
977        // `bcc_self` is enabled by default for test contexts. Unset it.
978        context1.set_config(Config::BccSelf, None).await?;
979
980        // Check that the settings are displayed correctly.
981        assert_eq!(
982            context1.get_config(Config::BccSelf).await?,
983            Some("0".to_string())
984        );
985        context1.set_config_bool(Config::IsChatmail, true).await?;
986
987        assert_eq!(context1.get_config_bool(Config::IsMuted).await?, false);
988        context1.set_config_bool(Config::IsMuted, true).await?;
989        assert_eq!(context1.get_config_bool(Config::IsMuted).await?, true);
990
991        imex(context1, ImexMode::ExportBackup, backup_dir.path(), None).await?;
992        let _event = context1
993            .evtracker
994            .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
995            .await;
996
997        let context2 = &TestContext::new().await;
998        let backup = has_backup(context2, backup_dir.path()).await?;
999        imex(context2, ImexMode::ImportBackup, backup.as_ref(), None).await?;
1000        let _event = context2
1001            .evtracker
1002            .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
1003            .await;
1004        assert!(context2.is_configured().await?);
1005        assert!(context2.is_chatmail().await?);
1006        for ctx in [context1, context2] {
1007            // BccSelf should be enabled automatically when exporting a backup
1008            assert_eq!(ctx.get_config_bool(Config::BccSelf).await?, true);
1009            assert_eq!(ctx.get_config_bool(Config::IsMuted).await?, true);
1010        }
1011        Ok(())
1012    }
1013
1014    /// Tests that [`crate::qr::DCBACKUP_VERSION`] is checked correctly.
1015    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1016    async fn test_import_backup_fails_because_of_dcbackup_version() -> Result<()> {
1017        let mut tcm = TestContextManager::new();
1018        let context1 = tcm.alice().await;
1019        let context2 = tcm.unconfigured().await;
1020
1021        assert!(context1.is_configured().await?);
1022        assert!(!context2.is_configured().await?);
1023
1024        let backup_dir = tempfile::tempdir().unwrap();
1025
1026        tcm.section("export from context1");
1027        assert!(
1028            imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
1029                .await
1030                .is_ok()
1031        );
1032        let _event = context1
1033            .evtracker
1034            .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
1035            .await;
1036        let backup = has_backup(&context2, backup_dir.path()).await?;
1037        let modified_backup = backup_dir.path().join("modified_backup.tar");
1038
1039        tcm.section("Change backup_version to be higher than DCBACKUP_VERSION");
1040        {
1041            let unpack_dir = tempfile::tempdir().unwrap();
1042            let mut ar = Archive::new(File::open(&backup).await?);
1043            ar.unpack(&unpack_dir).await?;
1044
1045            let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME));
1046            sql.open(&context2, "".to_string()).await?;
1047            assert_eq!(
1048                sql.get_raw_config_int("backup_version").await?.unwrap(),
1049                DCBACKUP_VERSION
1050            );
1051            sql.set_raw_config_int("backup_version", DCBACKUP_VERSION + 1)
1052                .await?;
1053            sql.close().await;
1054
1055            let modified_backup_file = File::create(&modified_backup).await?;
1056            let mut builder = tokio_tar::Builder::new(modified_backup_file);
1057            builder.append_dir_all("", unpack_dir.path()).await?;
1058            builder.finish().await?;
1059        }
1060
1061        tcm.section("import to context2");
1062        let err = imex(&context2, ImexMode::ImportBackup, &modified_backup, None)
1063            .await
1064            .unwrap_err();
1065        assert!(err.to_string().starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
1066
1067        // Some UIs show the error from the event to the user.
1068        // Therefore, it must also be a user-facing string, rather than some technical info:
1069        let err_event = context2
1070            .evtracker
1071            .get_matching(|evt| matches!(evt, EventType::Error(_)))
1072            .await;
1073        let EventType::Error(err_msg) = err_event else {
1074            unreachable!()
1075        };
1076        assert!(err_msg.starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
1077
1078        context2
1079            .evtracker
1080            .get_matching(|evt| matches!(evt, EventType::ImexProgress(0)))
1081            .await;
1082
1083        assert!(!context2.is_configured().await?);
1084        assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None);
1085
1086        Ok(())
1087    }
1088
1089    /// This is a regression test for
1090    /// https://github.com/deltachat/deltachat-android/issues/2263
1091    /// where the config cache wasn't reset properly after a backup.
1092    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1093    async fn test_import_backup_reset_config_cache() -> Result<()> {
1094        let backup_dir = tempfile::tempdir()?;
1095        let context1 = TestContext::new_alice().await;
1096        let context2 = TestContext::new().await;
1097        assert!(!context2.is_configured().await?);
1098
1099        // export from context1
1100        imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None).await?;
1101
1102        // import to context2
1103        let backup = has_backup(&context2, backup_dir.path()).await?;
1104        let context2_cloned = context2.clone();
1105        let handle = task::spawn(async move {
1106            imex(
1107                &context2_cloned,
1108                ImexMode::ImportBackup,
1109                backup.as_ref(),
1110                None,
1111            )
1112            .await
1113            .unwrap();
1114        });
1115
1116        while !handle.is_finished() {
1117            // The database is still unconfigured;
1118            // fill the config cache with the old value.
1119            context2.is_configured().await.ok();
1120            tokio::time::sleep(Duration::from_micros(1)).await;
1121        }
1122
1123        // Assert that the config cache has the new value now.
1124        assert!(context2.is_configured().await?);
1125
1126        Ok(())
1127    }
1128
1129    /// Tests importing a backup from Delta Chat 1.30.3 for Android (core v1.86.0).
1130    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1131    async fn test_import_ancient_backup() -> Result<()> {
1132        let mut tcm = TestContextManager::new();
1133        let context = &tcm.unconfigured().await;
1134
1135        let backup_path = Path::new("test-data/core-1.86.0-backup.tar");
1136        imex(context, ImexMode::ImportBackup, backup_path, None).await?;
1137
1138        Ok(())
1139    }
1140}