1use std::{
9 ffi::OsStr,
10 path::{Path, PathBuf},
11 time::Duration,
12};
13
14use chrono::{DateTime, Utc};
15use sqlx::{
16 SqlitePool,
17 sqlite::{SqliteConnectOptions, SqlitePoolOptions},
18};
19
20use crate::{
21 error::{DbError, DbResult},
22 pool::{connect, connect_read_only},
23};
24
25const EXCLUSIVITY_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
36
37pub async fn backup_to(pool: &SqlitePool, dest: &Path) -> DbResult<()> {
47 if tokio::fs::try_exists(dest).await.map_err(DbError::Io)? {
48 return Err(DbError::BackupDestinationExists(dest.to_path_buf()));
49 }
50
51 sqlx::query("VACUUM INTO ?")
56 .bind(dest.display().to_string())
57 .execute(pool)
58 .await
59 .map_err(DbError::Query)?;
60
61 validate_backup_file(dest).await
62}
63
64#[derive(Debug)]
67pub struct RestoreOutcome {
68 pub pool: SqlitePool,
69 pub pre_restore_backup: Option<PathBuf>,
72}
73
74pub async fn restore_from(
112 pool: SqlitePool,
113 db_path: &Path,
114 backup_path: &Path,
115 now: DateTime<Utc>,
116) -> DbResult<RestoreOutcome> {
117 validate_backup_file(backup_path).await?;
118 let backup_pool = connect_read_only(backup_path).await?;
119 backup_pool.close().await;
120
121 pool.close().await;
126
127 let pre_restore_backup = if tokio::fs::try_exists(db_path).await.map_err(DbError::Io)? {
128 Some(exclusive_pre_restore_snapshot(db_path, now).await?)
129 } else {
130 None
131 };
132
133 let tmp_path = sibling_path(db_path, ".restoring-tmp");
134 tokio::fs::copy(backup_path, &tmp_path)
135 .await
136 .map_err(DbError::Io)?;
137 tokio::fs::rename(&tmp_path, db_path)
138 .await
139 .map_err(DbError::Io)?;
140
141 for suffix in ["-wal", "-shm"] {
142 let sidecar = sibling_path(db_path, suffix);
143 if tokio::fs::try_exists(&sidecar).await.map_err(DbError::Io)? {
144 tokio::fs::remove_file(&sidecar)
145 .await
146 .map_err(DbError::Io)?;
147 }
148 }
149
150 let pool = connect(db_path).await?;
151 Ok(RestoreOutcome {
152 pool,
153 pre_restore_backup,
154 })
155}
156
157async fn exclusive_pre_restore_snapshot(db_path: &Path, now: DateTime<Utc>) -> DbResult<PathBuf> {
177 let options = SqliteConnectOptions::new()
178 .filename(db_path)
179 .busy_timeout(EXCLUSIVITY_PROBE_TIMEOUT);
180 let probe_pool = SqlitePoolOptions::new()
181 .max_connections(1)
182 .connect_with(options)
183 .await
184 .map_err(DbError::Connect)?;
185
186 let (busy, _log, _checkpointed): (i64, i64, i64) =
187 sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
188 .fetch_one(&probe_pool)
189 .await
190 .map_err(DbError::Query)?;
191 if busy != 0 {
192 probe_pool.close().await;
193 return Err(DbError::DatabaseInUse(db_path.to_path_buf()));
194 }
195
196 let sidecar = pre_restore_backup_path(db_path, now);
197 sqlx::query("VACUUM INTO ?")
198 .bind(sidecar.display().to_string())
199 .execute(&probe_pool)
200 .await
201 .map_err(DbError::Query)?;
202
203 probe_pool.close().await;
204 validate_backup_file(&sidecar).await?;
205 Ok(sidecar)
206}
207
208async fn validate_backup_file(path: &Path) -> DbResult<()> {
213 if !tokio::fs::try_exists(path).await.map_err(DbError::Io)? {
214 return Err(DbError::InvalidBackup(format!(
215 "{} does not exist",
216 path.display()
217 )));
218 }
219
220 let options = SqliteConnectOptions::new().filename(path).read_only(true);
221 let check_pool = SqlitePoolOptions::new()
222 .max_connections(1)
223 .connect_with(options)
224 .await
225 .map_err(|error| {
226 DbError::InvalidBackup(format!("failed to open as a SQLite database: {error}"))
227 })?;
228
229 let (integrity,): (String,) = sqlx::query_as("PRAGMA integrity_check")
230 .fetch_one(&check_pool)
231 .await
232 .map_err(|error| {
233 DbError::InvalidBackup(format!("failed to run integrity_check: {error}"))
234 })?;
235 if integrity != "ok" {
236 return Err(DbError::InvalidBackup(format!(
237 "integrity_check reported: {integrity}"
238 )));
239 }
240
241 let tune_runs_table_count: i64 = sqlx::query_scalar(
242 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tune_runs'",
243 )
244 .fetch_one(&check_pool)
245 .await
246 .map_err(|error| DbError::InvalidBackup(format!("failed to inspect its schema: {error}")))?;
247 if tune_runs_table_count == 0 {
248 return Err(DbError::InvalidBackup(
249 "missing the tune_runs table -- doesn't look like a bhtune database".to_string(),
250 ));
251 }
252
253 Ok(())
254}
255
256fn pre_restore_backup_path(db_path: &Path, now: DateTime<Utc>) -> PathBuf {
259 sibling_path(
260 db_path,
261 &format!(".pre-restore-{}.bak", now.format("%Y%m%dT%H%M%SZ")),
262 )
263}
264
265fn sibling_path(db_path: &Path, suffix: &str) -> PathBuf {
269 let file_name = db_path
270 .file_name()
271 .and_then(OsStr::to_str)
272 .unwrap_or("bhtune.db");
273 db_path.with_file_name(format!("{file_name}{suffix}"))
274}
275
276#[cfg(test)]
277mod tests {
278 use bhtune_core::built_in_templates;
279
280 use super::*;
281 use crate::models::{DcsTemplateRow, TemplateOrigin};
282
283 async fn template_names(pool: &SqlitePool) -> Vec<String> {
284 let mut names: Vec<String> = sqlx::query_scalar("SELECT name FROM dcs_templates")
285 .fetch_all(pool)
286 .await
287 .unwrap();
288 names.sort();
289 names
290 }
291
292 async fn seed_one_template(pool: &SqlitePool, name: &str, now: DateTime<Utc>) {
293 let mut template = built_in_templates().into_iter().next().unwrap();
294 template.name = name.to_string();
295 DcsTemplateRow::insert(pool, &template, TemplateOrigin::Builtin, now)
296 .await
297 .unwrap();
298 }
299
300 fn expect_invalid_backup(error: DbError) -> String {
301 match error {
302 DbError::InvalidBackup(message) => message,
303 other => panic!("expected InvalidBackup, got {other:?}"),
304 }
305 }
306
307 #[tokio::test]
308 async fn backup_to_produces_a_reopenable_file_with_the_same_data() {
309 let dir = tempfile::tempdir().unwrap();
310 let now = Utc::now();
311 let pool = connect(&dir.path().join("live.db")).await.unwrap();
312 seed_one_template(&pool, "Backed Up Template", now).await;
313
314 let dest = dir.path().join("backup.db");
315 backup_to(&pool, &dest).await.unwrap();
316 assert!(dest.exists());
317
318 let reopened = connect(&dest).await.unwrap();
319 assert_eq!(
320 template_names(&reopened).await,
321 vec!["Backed Up Template".to_string()]
322 );
323 }
324
325 #[tokio::test]
326 async fn backup_to_refuses_to_overwrite_an_existing_destination() {
327 let dir = tempfile::tempdir().unwrap();
328 let pool = connect(&dir.path().join("live.db")).await.unwrap();
329
330 let dest = dir.path().join("backup.db");
331 std::fs::write(&dest, b"already here").unwrap();
332
333 let err = backup_to(&pool, &dest).await.unwrap_err();
334 assert!(matches!(err, DbError::BackupDestinationExists(path) if path == dest));
335 assert_eq!(std::fs::read(&dest).unwrap(), b"already here");
337 }
338
339 #[tokio::test]
340 async fn restore_from_replaces_the_live_database_with_the_backups_contents() {
341 let dir = tempfile::tempdir().unwrap();
342 let now = Utc::now();
343
344 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
345 seed_one_template(&backup_source_pool, "From Backup", now).await;
346 let backup_path = dir.path().join("backup.db");
347 backup_to(&backup_source_pool, &backup_path).await.unwrap();
348
349 let live_path = dir.path().join("live.db");
350 let live_pool = connect(&live_path).await.unwrap();
351 seed_one_template(&live_pool, "Still Live", now).await;
352
353 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
354 .await
355 .unwrap();
356
357 assert_eq!(
358 template_names(&outcome.pool).await,
359 vec!["From Backup".to_string()],
360 "restoring must replace the live data with the backup's, not merge the two"
361 );
362 }
363
364 #[tokio::test]
365 async fn restore_from_writes_a_pre_restore_safety_copy_of_the_previous_live_file() {
366 let dir = tempfile::tempdir().unwrap();
367 let now = Utc::now();
368
369 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
370 let backup_path = dir.path().join("backup.db");
371 backup_to(&backup_source_pool, &backup_path).await.unwrap();
372
373 let live_path = dir.path().join("live.db");
374 let live_pool = connect(&live_path).await.unwrap();
375 seed_one_template(&live_pool, "About To Be Overwritten", now).await;
376
377 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
378 .await
379 .unwrap();
380
381 let safety_copy = outcome
382 .pre_restore_backup
383 .expect("a live db existed before the restore, so a safety copy must be made");
384 assert!(safety_copy.exists());
385 let safety_copy_pool = connect(&safety_copy).await.unwrap();
386 assert_eq!(
387 template_names(&safety_copy_pool).await,
388 vec!["About To Be Overwritten".to_string()],
389 "the safety copy must preserve the old data, not the newly restored data"
390 );
391 }
392
393 #[tokio::test]
394 async fn restore_from_reports_no_pre_restore_backup_when_db_path_is_a_fresh_install() {
395 let dir = tempfile::tempdir().unwrap();
396 let now = Utc::now();
397
398 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
399 let backup_path = dir.path().join("backup.db");
400 backup_to(&backup_source_pool, &backup_path).await.unwrap();
401
402 let live_path = dir.path().join("never-existed.db");
408 let live_pool = crate::pool::connect_in_memory().await.unwrap();
409
410 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
411 .await
412 .unwrap();
413 assert_eq!(outcome.pre_restore_backup, None);
414 }
415
416 #[tokio::test]
417 async fn restore_from_rejects_an_invalid_backup_file_without_touching_the_live_database() {
418 let dir = tempfile::tempdir().unwrap();
419 let now = Utc::now();
420
421 let live_path = dir.path().join("live.db");
422 let live_pool = connect(&live_path).await.unwrap();
423 seed_one_template(&live_pool, "Must Survive", now).await;
424
425 let bogus_backup = dir.path().join("not-a-database.db");
426 std::fs::write(&bogus_backup, b"not a sqlite file at all").unwrap();
427
428 let err = restore_from(live_pool, &live_path, &bogus_backup, now)
429 .await
430 .unwrap_err();
431 assert!(matches!(err, DbError::InvalidBackup(_)));
432
433 let live_pool_again = connect(&live_path).await.unwrap();
435 assert_eq!(
436 template_names(&live_pool_again).await,
437 vec!["Must Survive".to_string()]
438 );
439 }
440
441 #[tokio::test]
447 async fn validate_backup_file_reports_a_path_that_does_not_exist() {
448 let dir = tempfile::tempdir().unwrap();
449 let missing = dir.path().join("never-written.db");
450
451 let err = validate_backup_file(&missing).await.unwrap_err();
452 assert!(expect_invalid_backup(err).contains("does not exist"));
453 }
454
455 #[tokio::test]
456 async fn validate_backup_file_reports_a_path_that_cannot_be_opened_as_sqlite_at_all() {
457 let dir = tempfile::tempdir().unwrap();
458 let err = validate_backup_file(dir.path()).await.unwrap_err();
462 assert!(expect_invalid_backup(err).contains("failed to open as a SQLite database"));
463 }
464
465 #[tokio::test]
466 async fn validate_backup_file_reports_a_database_that_fails_its_integrity_check() {
467 let dir = tempfile::tempdir().unwrap();
468 let db_path = dir.path().join("corrupt.db");
469
470 let pool = connect(&db_path).await.unwrap();
471 pool.close().await;
472
473 use std::io::{Seek, SeekFrom, Write};
482 let mut file = std::fs::OpenOptions::new()
483 .write(true)
484 .open(&db_path)
485 .unwrap();
486 file.seek(SeekFrom::Start(36)).unwrap();
487 file.write_all(&5u32.to_be_bytes()).unwrap();
488 drop(file);
489
490 let err = validate_backup_file(&db_path).await.unwrap_err();
491 assert!(expect_invalid_backup(err).contains("integrity_check reported"));
492 }
493
494 #[tokio::test]
495 async fn validate_backup_file_reports_a_valid_sqlite_file_with_no_tune_runs_table() {
496 let dir = tempfile::tempdir().unwrap();
497 let db_path = dir.path().join("not-a-bhtune-db.db");
498
499 let options = SqliteConnectOptions::new()
503 .filename(&db_path)
504 .create_if_missing(true);
505 let pool = SqlitePoolOptions::new()
506 .max_connections(1)
507 .connect_with(options)
508 .await
509 .unwrap();
510 pool.close().await;
511
512 let err = validate_backup_file(&db_path).await.unwrap_err();
513 assert!(expect_invalid_backup(err).contains("tune_runs"));
514 }
515
516 #[test]
517 fn invalid_backup_assertion_fails_clearly_for_another_database_error() {
518 let panic = std::panic::catch_unwind(|| {
519 expect_invalid_backup(DbError::DatabaseInUse(PathBuf::from("live.db")))
520 })
521 .unwrap_err();
522 assert!(
523 panic
524 .downcast_ref::<String>()
525 .is_some_and(|message| message.contains("InvalidBackup"))
526 );
527 }
528
529 #[tokio::test]
530 async fn restore_from_removes_orphaned_wal_and_shm_sidecars_with_no_backing_connection() {
531 let dir = tempfile::tempdir().unwrap();
532 let now = Utc::now();
533
534 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
535 seed_one_template(&backup_source_pool, "From Backup", now).await;
536 let backup_path = dir.path().join("backup.db");
537 backup_to(&backup_source_pool, &backup_path).await.unwrap();
538
539 let live_path = dir.path().join("live.db");
540 let live_pool = connect(&live_path).await.unwrap();
541
542 live_pool.close().await;
551 let wal_path = sibling_path(&live_path, "-wal");
552 let shm_path = sibling_path(&live_path, "-shm");
553 std::fs::write(&wal_path, b"orphaned wal content").unwrap();
554 std::fs::write(&shm_path, b"orphaned shm content").unwrap();
555 assert!(wal_path.exists());
556 assert!(shm_path.exists());
557
558 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
561 .await
562 .unwrap();
563
564 assert_eq!(
569 template_names(&outcome.pool).await,
570 vec!["From Backup".to_string()]
571 );
572 }
573
574 #[tokio::test]
575 async fn restore_from_removes_orphaned_sidecars_even_when_db_path_itself_is_missing() {
576 let dir = tempfile::tempdir().unwrap();
577 let now = Utc::now();
578
579 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
580 seed_one_template(&backup_source_pool, "From Backup", now).await;
581 let backup_path = dir.path().join("backup.db");
582 backup_to(&backup_source_pool, &backup_path).await.unwrap();
583
584 let live_path = dir.path().join("live.db");
595 let wal_path = sibling_path(&live_path, "-wal");
596 let shm_path = sibling_path(&live_path, "-shm");
597 std::fs::write(&wal_path, b"orphaned wal content").unwrap();
598 std::fs::write(&shm_path, b"orphaned shm content").unwrap();
599
600 let live_pool = crate::pool::connect_in_memory().await.unwrap();
601 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
602 .await
603 .unwrap();
604
605 if let Ok(contents) = std::fs::read(&wal_path) {
610 assert_ne!(contents, b"orphaned wal content");
611 }
612 assert_eq!(
613 template_names(&outcome.pool).await,
614 vec!["From Backup".to_string()]
615 );
616 }
617
618 #[tokio::test]
619 async fn restore_from_preserves_the_live_database_when_staging_the_replacement_fails() {
620 let dir = tempfile::tempdir().unwrap();
621 let now = Utc::now();
622 let live_path = dir.path().join("live.db");
623 let backup_path = dir.path().join("backup.db");
624 let pool = connect(&live_path).await.unwrap();
625 seed_one_template(&pool, "Live Template", now).await;
626 backup_to(&pool, &backup_path).await.unwrap();
627
628 std::fs::create_dir(sibling_path(&live_path, ".restoring-tmp")).unwrap();
632 let error = restore_from(pool, &live_path, &backup_path, now)
633 .await
634 .unwrap_err();
635 assert!(matches!(error, DbError::Io(_)));
636
637 let reopened = connect(&live_path).await.unwrap();
638 assert_eq!(template_names(&reopened).await, vec!["Live Template"]);
639 }
640
641 #[tokio::test]
642 async fn restore_from_refuses_when_another_connection_still_holds_the_live_database_open() {
643 let dir = tempfile::tempdir().unwrap();
644 let now = Utc::now();
645
646 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
647 let backup_path = dir.path().join("backup.db");
648 backup_to(&backup_source_pool, &backup_path).await.unwrap();
649
650 let live_path = dir.path().join("live.db");
651 let live_pool = connect(&live_path).await.unwrap();
652 seed_one_template(&live_pool, "Must Survive The Refusal", now).await;
653
654 let blocker_pool = connect(&live_path).await.unwrap();
662 let mut blocker_tx = blocker_pool.begin().await.unwrap();
663 sqlx::query("SELECT COUNT(*) FROM tune_runs")
664 .fetch_one(&mut *blocker_tx)
665 .await
666 .unwrap();
667
668 let err = restore_from(live_pool, &live_path, &backup_path, now)
669 .await
670 .unwrap_err();
671 assert!(matches!(err, DbError::DatabaseInUse(path) if path == live_path));
672
673 drop(blocker_tx);
675 blocker_pool.close().await;
676 let live_pool_again = connect(&live_path).await.unwrap();
677 assert_eq!(
678 template_names(&live_pool_again).await,
679 vec!["Must Survive The Refusal".to_string()]
680 );
681 }
682
683 #[tokio::test]
684 async fn restore_from_succeeds_once_the_blocking_connection_is_released() {
685 let dir = tempfile::tempdir().unwrap();
686 let now = Utc::now();
687
688 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
689 seed_one_template(&backup_source_pool, "From Backup", now).await;
690 let backup_path = dir.path().join("backup.db");
691 backup_to(&backup_source_pool, &backup_path).await.unwrap();
692
693 let live_path = dir.path().join("live.db");
694 let live_pool = connect(&live_path).await.unwrap();
695
696 let blocker_pool = connect(&live_path).await.unwrap();
697 let mut blocker_tx = blocker_pool.begin().await.unwrap();
698 sqlx::query("SELECT COUNT(*) FROM tune_runs")
699 .fetch_one(&mut *blocker_tx)
700 .await
701 .unwrap();
702
703 let err = restore_from(live_pool, &live_path, &backup_path, now)
707 .await
708 .unwrap_err();
709 assert!(matches!(err, DbError::DatabaseInUse(_)));
710
711 drop(blocker_tx);
712 blocker_pool.close().await;
713
714 let live_pool_retry = connect(&live_path).await.unwrap();
715 let outcome = restore_from(live_pool_retry, &live_path, &backup_path, now)
716 .await
717 .unwrap();
718 assert_eq!(
719 template_names(&outcome.pool).await,
720 vec!["From Backup".to_string()],
721 "once the other connection releases its read snapshot, the check must clear and the restore proceed"
722 );
723 }
724}