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,
23};
24
25const EXCLUSIVITY_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
36
37pub async fn backup_to(pool: &SqlitePool, dest: &Path) -> DbResult<()> {
47 if dest.exists() {
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(
114 pool: SqlitePool,
115 db_path: &Path,
116 backup_path: &Path,
117 now: DateTime<Utc>,
118) -> DbResult<RestoreOutcome> {
119 validate_backup_file(backup_path).await?;
120
121 pool.close().await;
126
127 let pre_restore_backup = if db_path.exists() {
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 std::fs::copy(backup_path, &tmp_path).map_err(DbError::Io)?;
135 std::fs::rename(&tmp_path, db_path).map_err(DbError::Io)?;
136
137 for suffix in ["-wal", "-shm"] {
138 let sidecar = sibling_path(db_path, suffix);
139 if sidecar.exists() {
140 std::fs::remove_file(&sidecar).map_err(DbError::Io)?;
141 }
142 }
143
144 let pool = connect(db_path).await?;
145 Ok(RestoreOutcome {
146 pool,
147 pre_restore_backup,
148 })
149}
150
151async fn exclusive_pre_restore_snapshot(db_path: &Path, now: DateTime<Utc>) -> DbResult<PathBuf> {
171 let options = SqliteConnectOptions::new()
172 .filename(db_path)
173 .busy_timeout(EXCLUSIVITY_PROBE_TIMEOUT);
174 let probe_pool = SqlitePoolOptions::new()
175 .max_connections(1)
176 .connect_with(options)
177 .await
178 .map_err(DbError::Connect)?;
179
180 let (busy, _log, _checkpointed): (i64, i64, i64) =
181 sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
182 .fetch_one(&probe_pool)
183 .await
184 .map_err(DbError::Query)?;
185 if busy != 0 {
186 probe_pool.close().await;
187 return Err(DbError::DatabaseInUse(db_path.to_path_buf()));
188 }
189
190 let sidecar = pre_restore_backup_path(db_path, now);
191 sqlx::query("VACUUM INTO ?")
192 .bind(sidecar.display().to_string())
193 .execute(&probe_pool)
194 .await
195 .map_err(DbError::Query)?;
196
197 probe_pool.close().await;
198 validate_backup_file(&sidecar).await?;
199 Ok(sidecar)
200}
201
202async fn validate_backup_file(path: &Path) -> DbResult<()> {
207 if !path.exists() {
208 return Err(DbError::InvalidBackup(format!(
209 "{} does not exist",
210 path.display()
211 )));
212 }
213
214 let options = SqliteConnectOptions::new().filename(path).read_only(true);
215 let check_pool = SqlitePoolOptions::new()
216 .max_connections(1)
217 .connect_with(options)
218 .await
219 .map_err(|error| {
220 DbError::InvalidBackup(format!("failed to open as a SQLite database: {error}"))
221 })?;
222
223 let (integrity,): (String,) = sqlx::query_as("PRAGMA integrity_check")
224 .fetch_one(&check_pool)
225 .await
226 .map_err(|error| {
227 DbError::InvalidBackup(format!("failed to run integrity_check: {error}"))
228 })?;
229 if integrity != "ok" {
230 return Err(DbError::InvalidBackup(format!(
231 "integrity_check reported: {integrity}"
232 )));
233 }
234
235 let tune_runs_table_count: i64 = sqlx::query_scalar(
236 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tune_runs'",
237 )
238 .fetch_one(&check_pool)
239 .await
240 .map_err(|error| DbError::InvalidBackup(format!("failed to inspect its schema: {error}")))?;
241 if tune_runs_table_count == 0 {
242 return Err(DbError::InvalidBackup(
243 "missing the tune_runs table -- doesn't look like a bhtune database".to_string(),
244 ));
245 }
246
247 Ok(())
248}
249
250fn pre_restore_backup_path(db_path: &Path, now: DateTime<Utc>) -> PathBuf {
253 sibling_path(
254 db_path,
255 &format!(".pre-restore-{}.bak", now.format("%Y%m%dT%H%M%SZ")),
256 )
257}
258
259fn sibling_path(db_path: &Path, suffix: &str) -> PathBuf {
263 let file_name = db_path
264 .file_name()
265 .and_then(OsStr::to_str)
266 .unwrap_or("bhtune.db");
267 db_path.with_file_name(format!("{file_name}{suffix}"))
268}
269
270#[cfg(test)]
271mod tests {
272 use bhtune_core::built_in_templates;
273
274 use super::*;
275 use crate::models::{DcsTemplateRow, TemplateOrigin};
276
277 async fn template_names(pool: &SqlitePool) -> Vec<String> {
278 let mut names: Vec<String> = sqlx::query_scalar("SELECT name FROM dcs_templates")
279 .fetch_all(pool)
280 .await
281 .unwrap();
282 names.sort();
283 names
284 }
285
286 async fn seed_one_template(pool: &SqlitePool, name: &str, now: DateTime<Utc>) {
287 let mut template = built_in_templates().into_iter().next().unwrap();
288 template.name = name.to_string();
289 DcsTemplateRow::insert(pool, &template, TemplateOrigin::Builtin, now)
290 .await
291 .unwrap();
292 }
293
294 fn expect_invalid_backup(error: DbError) -> String {
295 match error {
296 DbError::InvalidBackup(message) => message,
297 other => panic!("expected InvalidBackup, got {other:?}"),
298 }
299 }
300
301 #[tokio::test]
302 async fn backup_to_produces_a_reopenable_file_with_the_same_data() {
303 let dir = tempfile::tempdir().unwrap();
304 let now = Utc::now();
305 let pool = connect(&dir.path().join("live.db")).await.unwrap();
306 seed_one_template(&pool, "Backed Up Template", now).await;
307
308 let dest = dir.path().join("backup.db");
309 backup_to(&pool, &dest).await.unwrap();
310 assert!(dest.exists());
311
312 let reopened = connect(&dest).await.unwrap();
313 assert_eq!(
314 template_names(&reopened).await,
315 vec!["Backed Up Template".to_string()]
316 );
317 }
318
319 #[tokio::test]
320 async fn backup_to_refuses_to_overwrite_an_existing_destination() {
321 let dir = tempfile::tempdir().unwrap();
322 let pool = connect(&dir.path().join("live.db")).await.unwrap();
323
324 let dest = dir.path().join("backup.db");
325 std::fs::write(&dest, b"already here").unwrap();
326
327 let err = backup_to(&pool, &dest).await.unwrap_err();
328 assert!(matches!(err, DbError::BackupDestinationExists(path) if path == dest));
329 assert_eq!(std::fs::read(&dest).unwrap(), b"already here");
331 }
332
333 #[tokio::test]
334 async fn restore_from_replaces_the_live_database_with_the_backups_contents() {
335 let dir = tempfile::tempdir().unwrap();
336 let now = Utc::now();
337
338 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
339 seed_one_template(&backup_source_pool, "From Backup", now).await;
340 let backup_path = dir.path().join("backup.db");
341 backup_to(&backup_source_pool, &backup_path).await.unwrap();
342
343 let live_path = dir.path().join("live.db");
344 let live_pool = connect(&live_path).await.unwrap();
345 seed_one_template(&live_pool, "Still Live", now).await;
346
347 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
348 .await
349 .unwrap();
350
351 assert_eq!(
352 template_names(&outcome.pool).await,
353 vec!["From Backup".to_string()],
354 "restoring must replace the live data with the backup's, not merge the two"
355 );
356 }
357
358 #[tokio::test]
359 async fn restore_from_writes_a_pre_restore_safety_copy_of_the_previous_live_file() {
360 let dir = tempfile::tempdir().unwrap();
361 let now = Utc::now();
362
363 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
364 let backup_path = dir.path().join("backup.db");
365 backup_to(&backup_source_pool, &backup_path).await.unwrap();
366
367 let live_path = dir.path().join("live.db");
368 let live_pool = connect(&live_path).await.unwrap();
369 seed_one_template(&live_pool, "About To Be Overwritten", now).await;
370
371 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
372 .await
373 .unwrap();
374
375 let safety_copy = outcome
376 .pre_restore_backup
377 .expect("a live db existed before the restore, so a safety copy must be made");
378 assert!(safety_copy.exists());
379 let safety_copy_pool = connect(&safety_copy).await.unwrap();
380 assert_eq!(
381 template_names(&safety_copy_pool).await,
382 vec!["About To Be Overwritten".to_string()],
383 "the safety copy must preserve the old data, not the newly restored data"
384 );
385 }
386
387 #[tokio::test]
388 async fn restore_from_reports_no_pre_restore_backup_when_db_path_is_a_fresh_install() {
389 let dir = tempfile::tempdir().unwrap();
390 let now = Utc::now();
391
392 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
393 let backup_path = dir.path().join("backup.db");
394 backup_to(&backup_source_pool, &backup_path).await.unwrap();
395
396 let live_path = dir.path().join("never-existed.db");
402 let live_pool = crate::pool::connect_in_memory().await.unwrap();
403
404 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
405 .await
406 .unwrap();
407 assert_eq!(outcome.pre_restore_backup, None);
408 }
409
410 #[tokio::test]
411 async fn restore_from_rejects_an_invalid_backup_file_without_touching_the_live_database() {
412 let dir = tempfile::tempdir().unwrap();
413 let now = Utc::now();
414
415 let live_path = dir.path().join("live.db");
416 let live_pool = connect(&live_path).await.unwrap();
417 seed_one_template(&live_pool, "Must Survive", now).await;
418
419 let bogus_backup = dir.path().join("not-a-database.db");
420 std::fs::write(&bogus_backup, b"not a sqlite file at all").unwrap();
421
422 let err = restore_from(live_pool, &live_path, &bogus_backup, now)
423 .await
424 .unwrap_err();
425 assert!(matches!(err, DbError::InvalidBackup(_)));
426
427 let live_pool_again = connect(&live_path).await.unwrap();
429 assert_eq!(
430 template_names(&live_pool_again).await,
431 vec!["Must Survive".to_string()]
432 );
433 }
434
435 #[tokio::test]
441 async fn validate_backup_file_reports_a_path_that_does_not_exist() {
442 let dir = tempfile::tempdir().unwrap();
443 let missing = dir.path().join("never-written.db");
444
445 let err = validate_backup_file(&missing).await.unwrap_err();
446 assert!(expect_invalid_backup(err).contains("does not exist"));
447 }
448
449 #[tokio::test]
450 async fn validate_backup_file_reports_a_path_that_cannot_be_opened_as_sqlite_at_all() {
451 let dir = tempfile::tempdir().unwrap();
452 let err = validate_backup_file(dir.path()).await.unwrap_err();
456 assert!(expect_invalid_backup(err).contains("failed to open as a SQLite database"));
457 }
458
459 #[tokio::test]
460 async fn validate_backup_file_reports_a_database_that_fails_its_integrity_check() {
461 let dir = tempfile::tempdir().unwrap();
462 let db_path = dir.path().join("corrupt.db");
463
464 let pool = connect(&db_path).await.unwrap();
465 pool.close().await;
466
467 use std::io::{Seek, SeekFrom, Write};
476 let mut file = std::fs::OpenOptions::new()
477 .write(true)
478 .open(&db_path)
479 .unwrap();
480 file.seek(SeekFrom::Start(36)).unwrap();
481 file.write_all(&5u32.to_be_bytes()).unwrap();
482 drop(file);
483
484 let err = validate_backup_file(&db_path).await.unwrap_err();
485 assert!(expect_invalid_backup(err).contains("integrity_check reported"));
486 }
487
488 #[tokio::test]
489 async fn validate_backup_file_reports_a_valid_sqlite_file_with_no_tune_runs_table() {
490 let dir = tempfile::tempdir().unwrap();
491 let db_path = dir.path().join("not-a-bhtune-db.db");
492
493 let options = SqliteConnectOptions::new()
497 .filename(&db_path)
498 .create_if_missing(true);
499 let pool = SqlitePoolOptions::new()
500 .max_connections(1)
501 .connect_with(options)
502 .await
503 .unwrap();
504 pool.close().await;
505
506 let err = validate_backup_file(&db_path).await.unwrap_err();
507 assert!(expect_invalid_backup(err).contains("tune_runs"));
508 }
509
510 #[test]
511 fn invalid_backup_assertion_fails_clearly_for_another_database_error() {
512 let panic = std::panic::catch_unwind(|| {
513 expect_invalid_backup(DbError::DatabaseInUse(PathBuf::from("live.db")))
514 })
515 .unwrap_err();
516 assert!(
517 panic
518 .downcast_ref::<String>()
519 .is_some_and(|message| message.contains("InvalidBackup"))
520 );
521 }
522
523 #[tokio::test]
524 async fn restore_from_removes_orphaned_wal_and_shm_sidecars_with_no_backing_connection() {
525 let dir = tempfile::tempdir().unwrap();
526 let now = Utc::now();
527
528 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
529 seed_one_template(&backup_source_pool, "From Backup", now).await;
530 let backup_path = dir.path().join("backup.db");
531 backup_to(&backup_source_pool, &backup_path).await.unwrap();
532
533 let live_path = dir.path().join("live.db");
534 let live_pool = connect(&live_path).await.unwrap();
535
536 live_pool.close().await;
545 let wal_path = sibling_path(&live_path, "-wal");
546 let shm_path = sibling_path(&live_path, "-shm");
547 std::fs::write(&wal_path, b"orphaned wal content").unwrap();
548 std::fs::write(&shm_path, b"orphaned shm content").unwrap();
549 assert!(wal_path.exists());
550 assert!(shm_path.exists());
551
552 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
555 .await
556 .unwrap();
557
558 assert_eq!(
563 template_names(&outcome.pool).await,
564 vec!["From Backup".to_string()]
565 );
566 }
567
568 #[tokio::test]
569 async fn restore_from_removes_orphaned_sidecars_even_when_db_path_itself_is_missing() {
570 let dir = tempfile::tempdir().unwrap();
571 let now = Utc::now();
572
573 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
574 seed_one_template(&backup_source_pool, "From Backup", now).await;
575 let backup_path = dir.path().join("backup.db");
576 backup_to(&backup_source_pool, &backup_path).await.unwrap();
577
578 let live_path = dir.path().join("live.db");
589 let wal_path = sibling_path(&live_path, "-wal");
590 let shm_path = sibling_path(&live_path, "-shm");
591 std::fs::write(&wal_path, b"orphaned wal content").unwrap();
592 std::fs::write(&shm_path, b"orphaned shm content").unwrap();
593
594 let live_pool = crate::pool::connect_in_memory().await.unwrap();
595 let outcome = restore_from(live_pool, &live_path, &backup_path, now)
596 .await
597 .unwrap();
598
599 if let Ok(contents) = std::fs::read(&wal_path) {
604 assert_ne!(contents, b"orphaned wal content");
605 }
606 assert_eq!(
607 template_names(&outcome.pool).await,
608 vec!["From Backup".to_string()]
609 );
610 }
611
612 #[tokio::test]
613 async fn restore_from_preserves_the_live_database_when_staging_the_replacement_fails() {
614 let dir = tempfile::tempdir().unwrap();
615 let now = Utc::now();
616 let live_path = dir.path().join("live.db");
617 let backup_path = dir.path().join("backup.db");
618 let pool = connect(&live_path).await.unwrap();
619 seed_one_template(&pool, "Live Template", now).await;
620 backup_to(&pool, &backup_path).await.unwrap();
621
622 std::fs::create_dir(sibling_path(&live_path, ".restoring-tmp")).unwrap();
626 let error = restore_from(pool, &live_path, &backup_path, now)
627 .await
628 .unwrap_err();
629 assert!(matches!(error, DbError::Io(_)));
630
631 let reopened = connect(&live_path).await.unwrap();
632 assert_eq!(template_names(&reopened).await, vec!["Live Template"]);
633 }
634
635 #[tokio::test]
636 async fn restore_from_refuses_when_another_connection_still_holds_the_live_database_open() {
637 let dir = tempfile::tempdir().unwrap();
638 let now = Utc::now();
639
640 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
641 let backup_path = dir.path().join("backup.db");
642 backup_to(&backup_source_pool, &backup_path).await.unwrap();
643
644 let live_path = dir.path().join("live.db");
645 let live_pool = connect(&live_path).await.unwrap();
646 seed_one_template(&live_pool, "Must Survive The Refusal", now).await;
647
648 let blocker_pool = connect(&live_path).await.unwrap();
656 let mut blocker_tx = blocker_pool.begin().await.unwrap();
657 sqlx::query("SELECT COUNT(*) FROM tune_runs")
658 .fetch_one(&mut *blocker_tx)
659 .await
660 .unwrap();
661
662 let err = restore_from(live_pool, &live_path, &backup_path, now)
663 .await
664 .unwrap_err();
665 assert!(matches!(err, DbError::DatabaseInUse(path) if path == live_path));
666
667 drop(blocker_tx);
669 blocker_pool.close().await;
670 let live_pool_again = connect(&live_path).await.unwrap();
671 assert_eq!(
672 template_names(&live_pool_again).await,
673 vec!["Must Survive The Refusal".to_string()]
674 );
675 }
676
677 #[tokio::test]
678 async fn restore_from_succeeds_once_the_blocking_connection_is_released() {
679 let dir = tempfile::tempdir().unwrap();
680 let now = Utc::now();
681
682 let backup_source_pool = connect(&dir.path().join("source.db")).await.unwrap();
683 seed_one_template(&backup_source_pool, "From Backup", now).await;
684 let backup_path = dir.path().join("backup.db");
685 backup_to(&backup_source_pool, &backup_path).await.unwrap();
686
687 let live_path = dir.path().join("live.db");
688 let live_pool = connect(&live_path).await.unwrap();
689
690 let blocker_pool = connect(&live_path).await.unwrap();
691 let mut blocker_tx = blocker_pool.begin().await.unwrap();
692 sqlx::query("SELECT COUNT(*) FROM tune_runs")
693 .fetch_one(&mut *blocker_tx)
694 .await
695 .unwrap();
696
697 let err = restore_from(live_pool, &live_path, &backup_path, now)
701 .await
702 .unwrap_err();
703 assert!(matches!(err, DbError::DatabaseInUse(_)));
704
705 drop(blocker_tx);
706 blocker_pool.close().await;
707
708 let live_pool_retry = connect(&live_path).await.unwrap();
709 let outcome = restore_from(live_pool_retry, &live_path, &backup_path, now)
710 .await
711 .unwrap();
712 assert_eq!(
713 template_names(&outcome.pool).await,
714 vec!["From Backup".to_string()],
715 "once the other connection releases its read snapshot, the check must clear and the restore proceed"
716 );
717 }
718}