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