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 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
270pub(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 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 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}