Skip to main content

aven_core/db/
backup.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result, bail};
6use sqlx::sqlite::SqliteConnectOptions;
7use sqlx::{Connection as _, SqliteConnection, SqlitePool};
8
9use super::MIGRATOR;
10use crate::private_fs;
11
12const MIGRATION_BACKUP_KEEP: usize = 20;
13
14pub(super) async fn backup_before_pending_migrations(
15    path: &Path,
16    existed_before_open: bool,
17    pool: &SqlitePool,
18) -> Result<()> {
19    if !migration_backups_enabled() || !existed_before_open || !has_pending_migrations(pool).await?
20    {
21        return Ok(());
22    }
23    let backup_path = migration_backup_path(path)?;
24    let mut conn = pool.acquire().await?;
25    backup_database_exact_with_connection(&mut conn, &backup_path).await?;
26    prune_migration_backups(path)?;
27    Ok(())
28}
29
30fn migration_backups_enabled() -> bool {
31    std::env::var_os("AVEN_DEV_MIGRATION_BACKUPS").is_some()
32}
33
34async fn has_pending_migrations(pool: &SqlitePool) -> Result<bool> {
35    let applied_versions =
36        match sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations")
37            .fetch_all(pool)
38            .await
39        {
40            Ok(versions) => versions,
41            Err(error) => {
42                let Some(db_error) = error.as_database_error() else {
43                    return Err(error.into());
44                };
45                if db_error.code().as_deref() == Some("1") {
46                    return Ok(MIGRATOR.iter().next().is_some());
47                }
48                return Err(error.into());
49            }
50        };
51    Ok(MIGRATOR
52        .iter()
53        .any(|migration| !applied_versions.contains(&migration.version)))
54}
55
56fn migration_backup_path(path: &Path) -> Result<PathBuf> {
57    default_sqlite_backup_path(path, "before-migrate")
58}
59
60pub fn default_backup_path(path: &Path, reason: &str) -> Result<PathBuf> {
61    backup_path_with_extension(path, reason, "aven-backup.tar.zst")
62}
63
64pub fn default_sqlite_backup_path(path: &Path, reason: &str) -> Result<PathBuf> {
65    backup_path_with_extension(path, reason, "sqlite")
66}
67
68fn backup_path_with_extension(path: &Path, reason: &str, extension: &str) -> Result<PathBuf> {
69    let parent = path.parent().unwrap_or_else(|| Path::new("."));
70    let backup_dir = parent.join("backups");
71    private_fs::create_dir_all(&backup_dir)
72        .with_context(|| format!("could not create {}", backup_dir.display()))?;
73    let stem = path
74        .file_name()
75        .and_then(|name| name.to_str())
76        .unwrap_or("db.sqlite");
77    Ok(backup_dir.join(format!(
78        "{stem}.{reason}-{}.{}",
79        backup_timestamp()?,
80        extension
81    )))
82}
83
84pub async fn backup_database(source: &Path, backup: &Path) -> Result<()> {
85    if !source.is_file() {
86        bail!("could not open source {}", source.display());
87    }
88    let _installation = super::installation::InstallationGuard::acquire_for_backup(source)?;
89    backup_database_unlocked(source, backup).await
90}
91
92async fn backup_database_unlocked(source: &Path, backup: &Path) -> Result<()> {
93    if !source.is_file() {
94        bail!("could not open source {}", source.display());
95    }
96    if let Some(parent) = backup.parent().filter(|p| !p.as_os_str().is_empty()) {
97        private_fs::create_dir_all(parent)
98            .with_context(|| format!("could not create {}", parent.display()))?;
99    }
100    let mut conn = SqliteConnection::connect_with(
101        &SqliteConnectOptions::new()
102            .filename(source)
103            .read_only(true)
104            .foreign_keys(true)
105            .busy_timeout(Duration::from_secs(5)),
106    )
107    .await
108    .with_context(|| format!("could not open source {}", source.display()))?;
109    backup_database_with_connection(&mut conn, backup).await
110}
111
112pub fn wal_path(path: &Path) -> PathBuf {
113    PathBuf::from(format!("{}-wal", path.display()))
114}
115
116pub fn shm_path(path: &Path) -> PathBuf {
117    PathBuf::from(format!("{}-shm", path.display()))
118}
119
120pub async fn restore_database_file(target: &Path, source: &Path) -> Result<PathBuf> {
121    let _installation = super::installation::InstallationGuard::acquire(target)?;
122    _installation.ensure_restore_target_unbound()?;
123    validate_sqlite_source(source).await?;
124    ensure_file_has_no_active_local_shared_capture(target, "target").await?;
125    let safety = create_restore_safety_backup(target).await?;
126    let mut staging = private_fs::sibling_tempfile(target)
127        .with_context(|| format!("could not stage {}", target.display()))?;
128    std::io::copy(&mut fs::File::open(source)?, staging.as_file_mut())
129        .with_context(|| format!("could not copy {}", source.display()))?;
130    staging.as_file().sync_all()?;
131    detach_backup_snapshot(staging.path()).await?;
132    for sidecar in [wal_path(target), shm_path(target)] {
133        if sidecar.exists() {
134            fs::remove_file(&sidecar)
135                .with_context(|| format!("could not remove {}", sidecar.display()))?;
136        }
137    }
138    staging
139        .persist(target)
140        .with_context(|| format!("could not replace {}", target.display()))?;
141    Ok(safety)
142}
143
144pub(crate) async fn create_restore_safety_backup(target: &Path) -> Result<PathBuf> {
145    let safety = default_sqlite_backup_path(target, "before-restore")?;
146    if target.exists() {
147        backup_database_unlocked(target, &safety).await?;
148    } else {
149        private_fs::create_new_file(&safety)
150            .with_context(|| format!("could not create {}", safety.display()))?;
151        SqliteConnection::connect_with(
152            &SqliteConnectOptions::new()
153                .filename(&safety)
154                .create_if_missing(true),
155        )
156        .await
157        .with_context(|| format!("could not create {}", safety.display()))?
158        .close()
159        .await?;
160    }
161    Ok(safety)
162}
163
164async fn validate_sqlite_source(source: &Path) -> Result<()> {
165    let mut conn = sqlx::SqliteConnection::connect_with(
166        &SqliteConnectOptions::new()
167            .filename(source)
168            .read_only(true)
169            .foreign_keys(true),
170    )
171    .await
172    .with_context(|| format!("could not open source {}", source.display()))?;
173    let quick_check: String = sqlx::query_scalar("PRAGMA quick_check")
174        .fetch_one(&mut conn)
175        .await?;
176    if quick_check != "ok" {
177        bail!("error backup-source-corrupt quick_check={quick_check}");
178    }
179    Ok(())
180}
181
182fn backup_timestamp() -> Result<u64> {
183    Ok(SystemTime::now()
184        .duration_since(UNIX_EPOCH)
185        .context("system clock is before unix epoch")?
186        .as_secs())
187}
188
189pub(crate) async fn backup_database_with_connection(
190    conn: &mut SqliteConnection,
191    backup: &Path,
192) -> Result<()> {
193    backup_database_with_connection_mode(conn, backup, true).await
194}
195
196async fn backup_database_exact_with_connection(
197    conn: &mut SqliteConnection,
198    backup: &Path,
199) -> Result<()> {
200    ensure_connection_has_no_active_local_shared_capture(conn, "backup source").await?;
201    backup_database_with_connection_mode(conn, backup, false).await
202}
203
204async fn backup_database_with_connection_mode(
205    conn: &mut SqliteConnection,
206    backup: &Path,
207    detach_sync: bool,
208) -> Result<()> {
209    wait_at_backup_precheck_boundary(backup).await;
210    let parent = backup
211        .parent()
212        .filter(|p| !p.as_os_str().is_empty())
213        .unwrap_or_else(|| Path::new("."));
214    private_fs::create_dir_all(parent)
215        .with_context(|| format!("could not create {}", parent.display()))?;
216    let staging_dir = private_fs::tempdir_in(parent, ".aven-sqlite-backup-")
217        .with_context(|| format!("could not create backup staging in {}", parent.display()))?;
218    let staging = staging_dir.path().join("database.sqlite");
219    // VACUUM INTO accepts an empty target, so the snapshot inherits this mode.
220    private_fs::create_new_file(&staging)
221        .with_context(|| format!("could not create {}", staging.display()))?;
222    sqlx::query("VACUUM INTO ?")
223        .bind(staging.display().to_string())
224        .execute(&mut *conn)
225        .await
226        .with_context(|| format!("could not back up database to {}", backup.display()))?;
227    if detach_sync {
228        detach_backup_snapshot(&staging).await?;
229    } else {
230        ensure_file_has_no_active_local_shared_capture(&staging, "backup-snapshot").await?;
231    }
232    fs::rename(&staging, backup)
233        .with_context(|| format!("could not replace {}", backup.display()))?;
234    Ok(())
235}
236
237pub(crate) async fn detach_backup_snapshot(path: &Path) -> Result<()> {
238    let mut conn = SqliteConnection::connect_with(
239        &SqliteConnectOptions::new()
240            .filename(path)
241            .foreign_keys(false)
242            .busy_timeout(Duration::from_secs(5)),
243    )
244    .await
245    .with_context(|| format!("could not prepare local-only backup {}", path.display()))?;
246    sqlx::query("PRAGMA secure_delete=ON")
247        .execute(&mut conn)
248        .await?;
249    let mut tx = conn.begin().await?;
250    clear_sync_state(&mut tx).await?;
251    tx.commit().await?;
252    let foreign_key_errors: i64 =
253        sqlx::query_scalar("SELECT COUNT(*) FROM pragma_foreign_key_check")
254            .fetch_one(&mut conn)
255            .await?;
256    if foreign_key_errors != 0 {
257        bail!("error backup-detach-foreign-key-check");
258    }
259    sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
260        .execute(&mut conn)
261        .await?;
262    sqlx::query("PRAGMA journal_mode=DELETE")
263        .execute(&mut conn)
264        .await?;
265    sqlx::query("VACUUM").execute(&mut conn).await?;
266    conn.close().await?;
267    Ok(())
268}
269
270/// Removes every local trace of sync: setup and enrollment state, membership,
271/// the cursor, the outbox and sync metadata. Tasks, images and their history
272/// stay, and the database reads as one that never synced.
273pub(crate) async fn clear_sync_state(conn: &mut SqliteConnection) -> Result<()> {
274    let tables: Vec<String> = sqlx::query_scalar(
275        "SELECT name FROM sqlite_master
276         WHERE type = 'table' AND (
277             name GLOB 'local_shared_capture_*'
278             OR name GLOB 'local_seed_*'
279             OR name GLOB 'local_peer_*'
280             OR name GLOB 'local_membership_*'
281             OR name GLOB 'local_e2ee_*'
282         )",
283    )
284    .fetch_all(&mut *conn)
285    .await?;
286    if tables
287        .iter()
288        .any(|table| table == "local_shared_capture_journal")
289    {
290        sqlx::query("UPDATE local_shared_capture_journal SET publication_owned = 0")
291            .execute(&mut *conn)
292            .await?;
293    }
294    if tables
295        .iter()
296        .any(|table| table == "local_seed_publication_intent")
297    {
298        sqlx::query("DELETE FROM local_seed_publication_intent")
299            .execute(&mut *conn)
300            .await?;
301    }
302    for table in tables {
303        let quoted = table.replace('"', "\"\"");
304        sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM \"{quoted}\"")))
305            .execute(&mut *conn)
306            .await?;
307    }
308    sqlx::query("DELETE FROM meta WHERE key LIKE 'sync_%' OR key LIKE 'e2ee_%'")
309        .execute(&mut *conn)
310        .await?;
311    sqlx::query(
312        "INSERT INTO meta(key, value) VALUES ('sync_cursor', '0'), ('sync_generation', '0')",
313    )
314    .execute(&mut *conn)
315    .await?;
316    Ok(())
317}
318
319impl super::Database {
320    /// Clears sync state from this open database; see [`clear_sync_state`].
321    pub(crate) async fn clear_sync_state(&self) -> Result<()> {
322        let mut conn = self.acquire_writer().await?;
323        sqlx::query("PRAGMA secure_delete=ON")
324            .execute(&mut *conn)
325            .await?;
326        let mut tx = super::begin_immediate(&mut conn).await?;
327        // Sync tables reference each other; check once all rows are gone.
328        sqlx::query("PRAGMA defer_foreign_keys=ON")
329            .execute(&mut *tx)
330            .await?;
331        clear_sync_state(&mut tx).await?;
332        tx.commit().await?;
333        Ok(())
334    }
335}
336
337async fn ensure_connection_has_no_active_local_shared_capture(
338    conn: &mut SqliteConnection,
339    role: &str,
340) -> Result<()> {
341    let table_exists: bool = sqlx::query_scalar(
342        "SELECT EXISTS(
343             SELECT 1 FROM sqlite_master
344             WHERE type = 'table' AND name = 'local_shared_capture_journal'
345         )",
346    )
347    .fetch_one(&mut *conn)
348    .await?;
349    if !table_exists {
350        return Ok(());
351    }
352    let source_table: bool = sqlx::query_scalar(
353        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name = 'local_seed_source')",
354    )
355    .fetch_one(&mut *conn)
356    .await?;
357    if source_table {
358        crate::sync::shared_state::adoption::ensure_unbound(conn).await?;
359    }
360    let active: bool = sqlx::query_scalar(
361        "SELECT EXISTS(SELECT 1 FROM local_shared_capture_journal WHERE singleton = 1)",
362    )
363    .fetch_one(&mut *conn)
364    .await?;
365    if active {
366        bail!(
367            "error local-shared-capture-active role={} hint=cancel-never-dispatched-capture-first",
368            role.replace(' ', "-")
369        );
370    }
371    Ok(())
372}
373
374pub(crate) async fn ensure_file_has_no_active_local_shared_capture(
375    path: &Path,
376    role: &str,
377) -> Result<()> {
378    if !path.exists() {
379        return Ok(());
380    }
381    let mut conn = SqliteConnection::connect_with(
382        &SqliteConnectOptions::new()
383            .filename(path)
384            .read_only(true)
385            .foreign_keys(true)
386            .busy_timeout(Duration::from_secs(5)),
387    )
388    .await
389    .with_context(|| format!("could not open {} {}", role, path.display()))?;
390    ensure_connection_has_no_active_local_shared_capture(&mut conn, role).await
391}
392
393#[cfg(test)]
394type BackupPrecheckBarrier = (
395    PathBuf,
396    tokio::sync::oneshot::Sender<()>,
397    tokio::sync::oneshot::Receiver<()>,
398);
399
400#[cfg(test)]
401static BACKUP_PRECHECK_BARRIER: std::sync::Mutex<Option<BackupPrecheckBarrier>> =
402    std::sync::Mutex::new(None);
403
404#[cfg(test)]
405async fn wait_at_backup_precheck_boundary(backup: &Path) {
406    let barrier = {
407        let mut guard = BACKUP_PRECHECK_BARRIER
408            .lock()
409            .expect("backup test barrier poisoned");
410        if guard
411            .as_ref()
412            .is_some_and(|(expected, _, _)| expected == backup)
413        {
414            guard.take()
415        } else {
416            None
417        }
418    };
419    if let Some((_, reached, resume)) = barrier {
420        let _ = reached.send(());
421        let _ = resume.await;
422    }
423}
424
425#[cfg(not(test))]
426async fn wait_at_backup_precheck_boundary(_backup: &Path) {}
427
428fn prune_migration_backups(path: &Path) -> Result<()> {
429    let Some(parent) = path.parent() else {
430        return Ok(());
431    };
432    let backup_dir = parent.join("backups");
433    let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
434        return Ok(());
435    };
436    let prefix = format!("{file_name}.before-migrate-");
437    let mut backups = fs::read_dir(&backup_dir)
438        .with_context(|| format!("could not read {}", backup_dir.display()))?
439        .filter_map(|entry| entry.ok())
440        .filter(|entry| {
441            entry
442                .file_name()
443                .to_str()
444                .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".sqlite"))
445        })
446        .collect::<Vec<_>>();
447    backups.sort_by_key(|entry| entry.file_name());
448    let remove_count = backups.len().saturating_sub(MIGRATION_BACKUP_KEEP);
449    for entry in backups.into_iter().take(remove_count) {
450        let path = entry.path();
451        fs::remove_file(&path).with_context(|| format!("could not remove {}", path.display()))?;
452    }
453    Ok(())
454}
455
456#[cfg(test)]
457mod tests {
458    use super::super::{Database, begin_immediate, get_meta, set_meta};
459    use super::*;
460
461    #[tokio::test]
462    async fn in_process_backup_captures_wal_and_replaces_destination() {
463        let temp = tempfile::tempdir().unwrap();
464        let source = temp.path().join("source.sqlite");
465        let backup = temp.path().join("backup.sqlite");
466        let database = Database::open(&source).await.unwrap();
467        let mut writer = database.acquire_writer().await.unwrap();
468        let mut tx = begin_immediate(&mut writer).await.unwrap();
469        set_meta(&mut tx, "backup-test", "first").await.unwrap();
470        tx.commit().await.unwrap();
471        drop(writer);
472        assert!(wal_path(&source).exists());
473
474        fs::write(&backup, b"existing destination").unwrap();
475        backup_database(&source, &backup).await.unwrap();
476        let mut backup_conn = SqliteConnection::connect_with(
477            &SqliteConnectOptions::new()
478                .filename(&backup)
479                .read_only(true),
480        )
481        .await
482        .unwrap();
483        assert_eq!(
484            get_meta(&mut backup_conn, "backup-test")
485                .await
486                .unwrap()
487                .as_deref(),
488            Some("first")
489        );
490        drop(backup_conn);
491
492        let mut writer = database.acquire_writer().await.unwrap();
493        set_meta(&mut writer, "backup-test", "second")
494            .await
495            .unwrap();
496        drop(writer);
497        backup_database(&source, &backup).await.unwrap();
498        let mut backup_conn = SqliteConnection::connect_with(
499            &SqliteConnectOptions::new()
500                .filename(&backup)
501                .read_only(true),
502        )
503        .await
504        .unwrap();
505        assert_eq!(
506            get_meta(&mut backup_conn, "backup-test")
507                .await
508                .unwrap()
509                .as_deref(),
510            Some("second")
511        );
512    }
513
514    #[cfg(unix)]
515    #[tokio::test]
516    async fn new_database_backup_and_restore_are_owner_only_under_permissive_umask() {
517        use crate::private_fs::test_umask::{Umask, mode};
518
519        let _umask = Umask::set(0o022);
520        let temp = tempfile::tempdir().unwrap();
521        let source = temp.path().join("source.sqlite");
522        let database = Database::open(&source).await.unwrap();
523        let mut writer = database.acquire_writer().await.unwrap();
524        set_meta(&mut writer, "mode-test", "value").await.unwrap();
525        drop(writer);
526        let _guard =
527            super::super::installation::InstallationGuard::acquire_for_backup(&source).unwrap();
528        for path in [
529            source.clone(),
530            wal_path(&source),
531            shm_path(&source),
532            temp.path().join("source.sqlite.aven-installation.lock"),
533        ] {
534            assert_eq!(mode(&path), 0o600, "{}", path.display());
535        }
536        drop(_guard);
537        let backup = temp.path().join("new-dir").join("backup.sqlite");
538        backup_database(&source, &backup).await.unwrap();
539        assert_eq!(mode(&backup), 0o600);
540        assert_eq!(mode(backup.parent().unwrap()), 0o700);
541
542        let target = temp.path().join("target.sqlite");
543        let safety = restore_database_file(&target, &backup).await.unwrap();
544        assert_eq!(mode(&target), 0o600);
545        assert_eq!(mode(&safety), 0o600);
546        assert_eq!(mode(safety.parent().unwrap()), 0o700);
547    }
548
549    #[cfg(unix)]
550    #[tokio::test]
551    async fn existing_database_keeps_its_mode_and_sidecars_follow_it() {
552        use std::os::unix::fs::PermissionsExt;
553
554        use crate::private_fs::test_umask::{Umask, mode};
555
556        let _umask = Umask::set(0o022);
557        let temp = tempfile::tempdir().unwrap();
558        let path = temp.path().join("shared.sqlite");
559        Database::open(&path).await.unwrap().pool.close().await;
560        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
561        let database = Database::open(&path).await.unwrap();
562        let mut writer = database.acquire_writer().await.unwrap();
563        set_meta(&mut writer, "mode-test", "value").await.unwrap();
564        drop(writer);
565        assert_eq!(mode(&path), 0o640);
566        assert_eq!(mode(&wal_path(&path)), 0o640);
567        assert_eq!(mode(&shm_path(&path)), 0o640);
568    }
569
570    #[cfg(unix)]
571    #[tokio::test]
572    async fn restore_ignores_planted_staging_symlink() {
573        use std::os::unix::fs::symlink;
574
575        let temp = tempfile::tempdir().unwrap();
576        let source = temp.path().join("source.sqlite");
577        Database::open(&source).await.unwrap().pool.close().await;
578        let victim = temp.path().join("victim");
579        fs::write(&victim, b"sentinel").unwrap();
580        let target = temp.path().join("target.sqlite");
581        let dangling = temp.path().join("dangling");
582        symlink(&dangling, target.with_extension("restore-staging")).unwrap();
583        symlink(&victim, target.with_extension("tmp")).unwrap();
584
585        restore_database_file(&target, &source).await.unwrap();
586
587        assert_eq!(fs::read(&victim).unwrap(), b"sentinel");
588        assert!(!dangling.exists());
589        let metadata = fs::symlink_metadata(&target).unwrap();
590        assert!(metadata.is_file() && !metadata.file_type().is_symlink());
591    }
592
593    #[cfg(unix)]
594    #[test]
595    fn lock_file_refuses_symlink_and_special_files() {
596        use std::os::unix::fs::symlink;
597
598        let temp = tempfile::tempdir().unwrap();
599        let victim = temp.path().join("victim");
600        fs::write(&victim, b"sentinel").unwrap();
601        let link = temp.path().join("link.lock");
602        symlink(&victim, &link).unwrap();
603        assert!(crate::private_fs::open_lock_file(&link).is_err());
604        assert!(crate::private_fs::open_lock_file(temp.path()).is_err());
605        assert_eq!(fs::read(&victim).unwrap(), b"sentinel");
606    }
607
608    #[tokio::test]
609    async fn in_process_backup_rejects_missing_source() {
610        let temp = tempfile::tempdir().unwrap();
611        let backup = temp.path().join("backup.sqlite");
612        let error = backup_database(&temp.path().join("missing.sqlite"), &backup)
613            .await
614            .unwrap_err();
615        assert!(error.to_string().contains("could not open source"));
616        assert!(!backup.exists());
617    }
618
619    #[tokio::test]
620    async fn restore_replaces_sidecars_and_preserves_safety_copy() {
621        let temp = tempfile::tempdir().unwrap();
622        let target = temp.path().join("target.sqlite");
623        let source = temp.path().join("source.sqlite");
624        let target_database = Database::open(&target).await.unwrap();
625        let source_database = Database::open(&source).await.unwrap();
626        let mut target_writer = target_database.acquire_writer().await.unwrap();
627        set_meta(&mut target_writer, "restore-test", "target")
628            .await
629            .unwrap();
630        drop(target_writer);
631        let mut source_writer = source_database.acquire_writer().await.unwrap();
632        set_meta(&mut source_writer, "restore-test", "source")
633            .await
634            .unwrap();
635        drop(source_writer);
636        target_database.pool.close().await;
637        source_database.pool.close().await;
638        fs::write(wal_path(&target), b"stale wal").unwrap();
639        fs::write(shm_path(&target), b"stale shm").unwrap();
640
641        let safety = restore_database_file(&target, &source).await.unwrap();
642        assert!(!wal_path(&target).exists());
643        assert!(!shm_path(&target).exists());
644
645        let mut restored = SqliteConnection::connect_with(
646            &SqliteConnectOptions::new()
647                .filename(&target)
648                .read_only(true),
649        )
650        .await
651        .unwrap();
652        assert_eq!(
653            get_meta(&mut restored, "restore-test")
654                .await
655                .unwrap()
656                .as_deref(),
657            Some("source")
658        );
659        let mut preserved = SqliteConnection::connect_with(
660            &SqliteConnectOptions::new()
661                .filename(&safety)
662                .read_only(true),
663        )
664        .await
665        .unwrap();
666        assert_eq!(
667            get_meta(&mut preserved, "restore-test")
668                .await
669                .unwrap()
670                .as_deref(),
671            Some("target")
672        );
673    }
674
675    #[tokio::test]
676    async fn restore_detaches_a_bound_source_database() {
677        let temp = tempfile::tempdir().unwrap();
678        let source = temp.path().join("source.sqlite");
679        let target = temp.path().join("target.sqlite");
680        let database = Database::open(&source).await.unwrap();
681        let mut conn = database.acquire_writer().await.unwrap();
682        set_meta(&mut conn, "restore-test", "preserved")
683            .await
684            .unwrap();
685        set_meta(&mut conn, "e2ee_association", "old-sync")
686            .await
687            .unwrap();
688        sqlx::query("INSERT INTO local_peer_enrollment VALUES (1, zeroblob(32), 'client', 'peer')")
689            .execute(&mut *conn)
690            .await
691            .unwrap();
692        drop(conn);
693        database.pool.close().await;
694
695        restore_database_file(&target, &source).await.unwrap();
696
697        let restored = Database::open(&target).await.unwrap();
698        assert_eq!(
699            restored.meta("restore-test").await.unwrap().as_deref(),
700            Some("preserved")
701        );
702        assert!(restored.enrollment_pin().await.unwrap().is_none());
703        assert!(restored.meta("e2ee_association").await.unwrap().is_none());
704    }
705
706    #[tokio::test]
707    async fn completed_snapshot_detaches_capture_committed_after_backup_precheck() {
708        let temp = tempfile::tempdir().unwrap();
709        let source = temp.path().join("source.sqlite");
710        let backup = temp.path().join("backup.sqlite");
711        let database = Database::open(&source).await.unwrap();
712        fs::write(&backup, b"existing destination").unwrap();
713        let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
714        let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
715        *BACKUP_PRECHECK_BARRIER
716            .lock()
717            .expect("backup test barrier poisoned") = Some((backup.clone(), reached_tx, resume_rx));
718
719        let source_for_backup = source.clone();
720        let backup_for_worker = backup.clone();
721        let worker =
722            tokio::spawn(
723                async move { backup_database(&source_for_backup, &backup_for_worker).await },
724            );
725        reached_rx.await.unwrap();
726        database
727            .capture_local_shared_state_never_dispatched()
728            .await
729            .unwrap();
730        resume_tx.send(()).unwrap();
731
732        worker.await.unwrap().unwrap();
733        let mut snapshot = SqliteConnection::connect_with(
734            &SqliteConnectOptions::new()
735                .filename(&backup)
736                .read_only(true),
737        )
738        .await
739        .unwrap();
740        let capture: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM local_shared_capture_journal")
741            .fetch_one(&mut snapshot)
742            .await
743            .unwrap();
744        assert_eq!(capture, 0);
745        assert!(
746            fs::read_dir(temp.path())
747                .unwrap()
748                .filter_map(|entry| entry.ok())
749                .all(|entry| !entry
750                    .file_name()
751                    .to_string_lossy()
752                    .starts_with(".aven-sqlite-backup-"))
753        );
754    }
755
756    #[tokio::test]
757    async fn backup_of_bound_installation_is_local_only() {
758        const SECRET_MARKER: &[u8] = b"aven-test-secret-source-authority-7f9d3a21";
759
760        let temp = tempfile::tempdir().unwrap();
761        let source = temp.path().join("source.sqlite");
762        let backup = temp.path().join("backup.sqlite");
763        let restored_path = temp.path().join("restored.sqlite");
764        let database = Database::open(&source).await.unwrap();
765        let mut conn = database.acquire_writer().await.unwrap();
766        sqlx::query("INSERT INTO local_seed_source VALUES (1, ?, 'client')")
767            .bind(SECRET_MARKER)
768            .execute(&mut *conn)
769            .await
770            .unwrap();
771        set_meta(&mut conn, "e2ee_association", "old-sync")
772            .await
773            .unwrap();
774        drop(conn);
775        let installation = super::super::installation::InstallationGuard::acquire(&source).unwrap();
776        installation.fence().unwrap();
777        drop(installation);
778
779        backup_database(&source, &backup).await.unwrap();
780
781        let mut snapshot = SqliteConnection::connect_with(
782            &SqliteConnectOptions::new()
783                .filename(&backup)
784                .read_only(true),
785        )
786        .await
787        .unwrap();
788        let bound: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM local_seed_source")
789            .fetch_one(&mut snapshot)
790            .await
791            .unwrap();
792        assert_eq!(bound, 0);
793        assert_eq!(
794            get_meta(&mut snapshot, "e2ee_association").await.unwrap(),
795            None
796        );
797        assert_eq!(
798            get_meta(&mut snapshot, "sync_generation")
799                .await
800                .unwrap()
801                .as_deref(),
802            Some("0")
803        );
804        assert!(!PathBuf::from(format!("{}.aven-e2ee-bound", backup.display())).exists());
805        drop(snapshot);
806        assert!(
807            !fs::read(&backup)
808                .unwrap()
809                .windows(SECRET_MARKER.len())
810                .any(|window| window == SECRET_MARKER)
811        );
812
813        database.pool.close().await;
814        restore_database_file(&restored_path, &source)
815            .await
816            .unwrap();
817        assert!(
818            !fs::read(&restored_path)
819                .unwrap()
820                .windows(SECRET_MARKER.len())
821                .any(|window| window == SECRET_MARKER)
822        );
823    }
824}