Skip to main content

bhtune_db/
backup.rs

1//! Full-database backup and restore: a single portable SQLite file out, and back in.
2//!
3//! Independent of the automatic age-based retention sweep — this is about moving or
4//! protecting an *entire* installation (support diagnostics, migrating to a new
5//! machine, or a safety net immediately before a risky operation), not about pruning
6//! individual runs. Exposed to both the CLI and the GUI.
7
8use 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
25/// How long the pre-restore exclusivity probe waits on a lock before concluding another
26/// connection is genuinely still using the database, rather than just finishing up.
27///
28/// Deliberately much shorter than [`crate::pool::connect`]'s own busy timeout (10 seconds):
29/// that timeout exists so ordinary contended *work* (a retention sweep, a large export) has
30/// room to finish rather than erroring out. This one exists only to answer "is anyone here
31/// right now", so it should fail fast — a restore command that appears to hang for ten
32/// seconds every time a read happened moments earlier would be a poor, confusing experience
33/// for what is meant to be a quick, decisive check. A short grace window still lets a
34/// genuinely fleeting reader (one that's already committing) avoid a false positive.
35const EXCLUSIVITY_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
36
37/// Writes a complete, consistent, compacted snapshot of `pool`'s database to `dest`.
38///
39/// Uses SQLite's own `VACUUM INTO`: an online operation (it doesn't block `pool`'s other
40/// readers/writers) that produces a plain, single-file, non-WAL database — the most portable
41/// on-disk form, with no `-wal`/`-shm` sidecar files that would also need copying.
42///
43/// `dest` must not already exist. `VACUUM INTO` refuses to overwrite a file, and that's the
44/// right behavior for a backup command: silently clobbering a previous backup because of a
45/// reused filename would be its own data-loss bug, not a convenience worth having.
46pub 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    // `VACUUM INTO`'s target is a string expression, not a path — SQLite has no notion of
52    // `std::path::Path`. A lossy conversion (rather than requiring valid UTF-8) is fine here:
53    // it only affects the extremely rare non-UTF-8 filename, and failing loudly for that
54    // vanishingly unlikely case isn't worth the extra fallible API surface.
55    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/// The result of a successful [`restore_from`]: the fresh pool to use going forward, and
65/// where the previously-live database was safety-copied to before being overwritten.
66#[derive(Debug)]
67pub struct RestoreOutcome {
68    pub pool: SqlitePool,
69    /// `None` only when `db_path` didn't exist yet (a fresh install being restored into for
70    /// the first time) — there was nothing to safety-copy.
71    pub pre_restore_backup: Option<PathBuf>,
72}
73
74/// Restores `db_path` from a [`backup_to`]-produced file, replacing its entire contents.
75///
76/// Takes `pool` **by value**, not `&SqlitePool`: restoring means the file underneath every
77/// existing connection is about to be replaced out from under them, so the caller's old pool
78/// must be given up, not merely borrowed. The type system then enforces that the old handle
79/// can't accidentally go on being used afterward — every caller must switch to
80/// [`RestoreOutcome::pool`].
81///
82/// Safety, in order:
83/// 1. `backup_path` is validated (`PRAGMA integrity_check`, a real `tune_runs` table, and
84///    compatible migration history) *before* anything about the live database is touched,
85///    so a corrupt, unrelated, or pre-squash file never gets a chance to destroy good data.
86/// 2. The caller's own connections to `db_path` are closed first, so step 3's exclusivity
87///    check isn't confused by this process's own still-open pool.
88/// 3. If `db_path` already exists, `exclusive_pre_restore_snapshot` both confirms no other
89///    connection — in this process or another — still holds it open, and, while that's
90///    proven true, takes a consistent `VACUUM INTO` copy of it (see
91///    [`RestoreOutcome::pre_restore_backup`]). Restoring the wrong backup, or restoring when
92///    a fresh backup was what was actually wanted, is still recoverable afterward. Using
93///    `VACUUM INTO` here rather than a raw file copy means the safety copy can never be
94///    silently missing data that was still sitting in a WAL file — the same reason
95///    [`backup_to`] uses it.
96/// 4. The backup is copied into place via write-to-a-temp-file-then-rename, so a crash or a
97///    full disk mid-copy leaves the original `db_path` untouched rather than half-overwritten
98///    (rename onto an existing path is atomic on the same filesystem, which a same-directory
99///    temp file guarantees; renaming a file onto an existing file — as opposed to a
100///    directory — replaces it on Windows too, via `MOVEFILE_REPLACE_EXISTING`, so no
101///    Windows-specific fallback is needed here).
102/// 5. Any stale `-wal`/`-shm` sidecar files left over from the old `db_path` are removed —
103///    they describe uncommitted changes to a database that, after step 4, no longer exists
104///    at that path.
105/// 6. Supported backups are reopened via [`connect`] to apply standard pragmas.
106///
107/// Restoring while *another* bhtune process (for instance `bhtune-server`, running
108/// alongside the CLI) has `db_path` open returns [`DbError::DatabaseInUse`] instead of
109/// proceeding — see `exclusive_pre_restore_snapshot` for how that's detected and its
110/// residual, deliberately-accepted race.
111pub 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    // Wait for every connection to be gracefully closed (not just dropped) before touching
122    // the file at the OS level — an open file handle can otherwise block the rename/delete
123    // calls below outright, especially on Windows, and so the exclusivity probe below isn't
124    // confused by this process's own still-open connections.
125    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
157/// Confirms nothing else still holds `db_path` open, and — while that exclusivity is
158/// proven — takes a `VACUUM INTO` safety copy of it before [`restore_from`] overwrites it.
159///
160/// The check is `PRAGMA wal_checkpoint(TRUNCATE)`'s own `busy` column: fully truncating the
161/// WAL requires that no other connection, in this process or any other, is still reading or
162/// writing the database, so a nonzero `busy` result is SQLite's own proof that something
163/// else has it open. No separate lock file or advisory-lock scheme is needed to get that
164/// answer.
165///
166/// This is a point-in-time check, not a held lock: nothing stops a different process from
167/// opening `db_path` in the moment between this returning and [`restore_from`]'s later file
168/// replacement. That residual race is accepted deliberately — it's the "honest fix for the
169/// multi-process case" this was designed for, not a claim of a full distributed lock. A
170/// truly exclusive, lock-held-for-the-whole-restore guarantee would need every bhtune
171/// process to cooperate through a shared lock file from the moment it opens the database,
172/// which is a larger change than this finding's scope.
173///
174/// Deliberately does not go through [`connect`]: that runs migrations, which this must not
175/// do against a database that's about to be discarded and replaced wholesale.
176async 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
208/// Opens `path` read-only and confirms it's a usable bhtune database: SQLite's own
209/// `PRAGMA integrity_check`, plus a real `tune_runs` table (a cheap proxy for "this is a
210/// bhtune database", not just any SQLite file). Read-only so validating a backup can never
211/// itself be the thing that corrupts or migrates it.
212async 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
256/// A backup taken immediately before `db_path` was overwritten by [`restore_from`], named
257/// `<db file name>.pre-restore-<UTC timestamp>.bak` in the same directory.
258fn 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
265/// `db_path` with `suffix` appended directly to its file name (not its extension replaced —
266/// SQLite's own `-wal`/`-shm` sidecar convention is a literal suffix, e.g. `bhtune.db-wal`,
267/// which [`Path::with_extension`] cannot express).
268fn 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        // The pre-existing file must be left exactly as it was, not touched or truncated.
336        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        // A path that has never been connect()'d, matching a fresh install choosing to
403        // "restore" as its very first action instead of starting empty. `restore_from`
404        // doesn't require its `pool` argument to already be open against `db_path` — it
405        // only needs a pool it can close before touching the file — so an unrelated
406        // in-memory pool exercises this branch just as well as a real one would.
407        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        // Validation must run before the live database is touched at all.
434        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    // The remaining tests exercise `validate_backup_file`'s four distinct failure branches
442    // directly, since `restore_from`/`backup_to` collapse all of them to the same
443    // `DbError::InvalidBackup` variant and none of these specific underlying causes are
444    // otherwise reachable through the public API alone.
445
446    #[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        // A directory exists, so the earlier existence check passes, but SQLite
459        // cannot open a directory as a database file — this fails at connection time,
460        // before any query (including `PRAGMA integrity_check`) is ever issued.
461        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        // SQLite's file header stores "total number of freelist pages" at byte offset 36
474        // (big-endian u32); a fresh database has no freed pages, so the linked list of
475        // freelist trunk pages is empty and this count is 0. Declaring a nonzero count here
476        // (without actually creating any freelist pages) is a direct, header-only
477        // inconsistency that `PRAGMA integrity_check` detects deterministically -- it
478        // doesn't depend on the table schema or how much data is in the file, unlike
479        // corrupting page content, which is fragile across page-layout and free-space
480        // details `integrity_check` may or may not happen to walk over.
481        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        // A genuine, valid, empty SQLite database that never went through bhtune's own
500        // `connect()`/migrations -- passes `integrity_check` cleanly, but has no schema at
501        // all, let alone a `tune_runs` table.
502        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        // A graceful `Pool::close()` on the *last* connection to a database already makes
543        // SQLite clean up its own real WAL/SHM sidecars (confirmed empirically), so they
544        // can never be observed as "stale" by the time `restore_from` reaches its own
545        // removal loop. Closing here first, then writing garbage bytes to the same sidecar
546        // paths, simulates the case the removal loop actually exists for: files orphaned
547        // by something with no live connection at all (an unclean shutdown, a killed
548        // process, or a previous restore interrupted between steps) — not ordinary ones a
549        // clean close already disposes of.
550        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        // `Pool::close()` is safe to call again inside `restore_from` on an already-closed
559        // pool, so the same (closed) handle can still be moved in by value here.
560        let outcome = restore_from(live_pool, &live_path, &backup_path, now)
561            .await
562            .unwrap();
563
564        // The proactive removal in `restore_from` (rather than relying on SQLite's own
565        // salt-mismatch detection to safely ignore an incompatible WAL) means the restored
566        // database reopens cleanly with exactly the backup's data — not blocked, and not
567        // confused by the stale bytes that were sitting at the same sidecar paths.
568        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        // `db_path` itself has never existed -- so the pre-restore exclusivity check and
585        // safety snapshot are skipped entirely, there being nothing live to check or copy
586        // -- but stale `-wal`/`-shm` sidecar files sit at the paths it would use anyway,
587        // e.g. left behind by something that removed just the main file by hand. These
588        // must still be cleared before the restored file is written there, or the freshly
589        // restored database would be confused by unrelated WAL content sitting beside it.
590        // This is the one path that can reach the sidecar-removal loop directly: whenever
591        // `db_path` already exists, [`exclusive_pre_restore_snapshot`]'s own
592        // open-checkpoint-close sequence already disposes of any stale sidecars as a side
593        // effect, before the removal loop ever runs.
594        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        // `connect()`'s own final reopen legitimately creates a fresh `-wal` file for the
606        // new pool (ordinary WAL-mode operation), so the sidecar path may exist again by
607        // now -- what the removal loop must guarantee is that the *garbage* content is
608        // gone, not that no file is ever present at that path again.
609        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        // `restore_from` stages the replacement beside the live database. A directory at
629        // that exact staging path makes the copy fail before the atomic rename can touch
630        // the live file.
631        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        // A second, independent connection to the same file with an open read transaction,
655        // simulating another bhtune process (e.g. `bhtune-server` running alongside the
656        // CLI) that still has the database open. `restore_from` only closes the pool *it*
657        // was handed, so this second connection is exactly what the exclusivity probe
658        // inside `exclusive_pre_restore_snapshot` exists to notice. A `SELECT` (not just
659        // `BEGIN`) is required to actually establish a WAL read snapshot -- an empty
660        // transaction holds no lock a checkpoint would need to wait on.
661        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        // The live database must be left completely untouched by the refused attempt.
674        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        // `restore_from` already closed `live_pool` before discovering the blocker, so that
704        // handle is spent regardless of the outcome -- a real caller (e.g. a CLI command)
705        // would need to reconnect before trying again, exactly as this test does below.
706        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}