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