Skip to main content

miku/
backup.rs

1//! Single-file backup and restore for the libSQL database.
2//!
3//! Backup uses `VACUUM INTO`, which writes a fully-checkpointed, consistent
4//! snapshot to a brand-new file with no `-wal`/`-shm` sidecars, regardless of
5//! the live database's journal mode. Restore validates the snapshot, takes a
6//! safety backup of the current database, swaps the file into place atomically,
7//! and then deletes any stale WAL sidecars — left behind they would be replayed
8//! onto the restored file and corrupt it.
9//!
10//! Hardening: both operations hold an exclusive lock file so a concurrent
11//! backup/restore (or a running MCP server doing the same) can't race; snapshots
12//! are written `0600` (dir `0700`) since the graph may hold secrets; every
13//! snapshot is `PRAGMA integrity_check`-verified before it is trusted; and the
14//! default backup directory is pruned to the most recent `keep` snapshots.
15
16use anyhow::{Context, Result, bail};
17use libsql::{Builder, Connection, Database};
18use std::path::{Path, PathBuf};
19use tracing::{info, warn};
20
21use crate::constant::{
22    ENV_DATABASE_URL, SQL_INTEGRITY_CHECK, SQL_TABLE_EXISTS, SQL_VACUUM_INTO_TEMPLATE,
23};
24use crate::paths::MikuPaths;
25
26/// Tables a valid Miku snapshot must contain.
27const REQUIRED_TABLES: [&str; 2] = ["mcp_entities", "topics"];
28/// Prefix for snapshots produced by the `backup` command (pruned by retention).
29const BACKUP_PREFIX: &str = "miku";
30/// Prefix for the safety snapshot taken before a restore (kept, not pruned).
31const PRE_RESTORE_PREFIX: &str = "pre-restore";
32
33/// The live database file the CLI operates on: `MIKU_DATABASE_URL` if set,
34/// otherwise the resolved workspace `db_path()`. Backup and restore both go
35/// through this so they always target the same file as [`crate::db::init_db`].
36pub fn effective_db_path() -> PathBuf {
37    std::env::var(ENV_DATABASE_URL)
38        .map(PathBuf::from)
39        .unwrap_or_else(|_| MikuPaths::resolve().db_path())
40}
41
42/// Directory holding managed snapshots, co-located with the live DB so backups
43/// stay next to the data they snapshot even under `MIKU_DATABASE_URL`.
44fn backups_dir() -> PathBuf {
45    effective_db_path()
46        .parent()
47        .unwrap_or_else(|| Path::new("."))
48        .join("backups")
49}
50
51/// `<backups_dir>/<prefix>-YYYYMMDD-HHMMSSmmm.db`. Millisecond precision keeps
52/// two snapshots taken in the same second from colliding.
53fn default_backup_path(prefix: &str) -> PathBuf {
54    let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S%3f");
55    backups_dir().join(format!("{prefix}-{stamp}.db"))
56}
57
58/// Snapshot the live database to `output` (or a timestamped default in the
59/// managed directory) via `VACUUM INTO`, verify its integrity, and — when using
60/// the default location — prune to the newest `keep` snapshots. Returns the path
61/// written. Refuses to overwrite an existing file.
62pub async fn backup(conn: &Connection, output: Option<PathBuf>, keep: usize) -> Result<PathBuf> {
63    let _lock = OperationLock::acquire(&effective_db_path())?;
64    let is_default = output.is_none();
65    let dest = backup_to(conn, output).await?;
66    if is_default {
67        prune_backups(BACKUP_PREFIX, keep);
68    }
69    Ok(dest)
70}
71
72/// Core snapshot logic without locking or retention, so [`restore`] can reuse it
73/// for the pre-restore safety backup while already holding the lock.
74async fn backup_to(conn: &Connection, output: Option<PathBuf>) -> Result<PathBuf> {
75    let dest = output.unwrap_or_else(|| default_backup_path(BACKUP_PREFIX));
76    if dest.exists() {
77        bail!("backup target already exists: {}", dest.display());
78    }
79    if let Some(parent) = dest.parent() {
80        std::fs::create_dir_all(parent)
81            .with_context(|| format!("creating backup directory {}", parent.display()))?;
82        restrict_permissions(parent, 0o700)?;
83    }
84
85    let dest_str = dest.to_str().context("backup path is not valid UTF-8")?;
86    let sql = SQL_VACUUM_INTO_TEMPLATE.replace("{}", &dest_str.replace('\'', "''"));
87    conn.execute(&sql, ())
88        .await
89        .with_context(|| format!("vacuuming into {}", dest.display()))?;
90    restrict_permissions(&dest, 0o600)?;
91
92    assert_integrity(&dest)
93        .await
94        .with_context(|| format!("verifying snapshot {}", dest.display()))?;
95
96    Ok(dest)
97}
98
99/// Replace the live database with `source`. Consumes the open `db`/`conn` so the
100/// connection is fully closed (and the WAL flushed) before the file is swapped.
101pub async fn restore(db: Database, conn: Connection, source: &Path, force: bool) -> Result<()> {
102    let db_path = effective_db_path();
103    let _lock = OperationLock::acquire(&db_path)?;
104
105    if !source.is_file() {
106        bail!("restore source not found: {}", source.display());
107    }
108    validate_snapshot(source)
109        .await
110        .with_context(|| format!("{} is not a valid Miku database", source.display()))?;
111
112    if !force && !confirm_restore(&db_path, source)? {
113        info!("Restore aborted.");
114        return Ok(());
115    }
116
117    // Safety snapshot of the current database before we overwrite it.
118    let safety = backup_to(&conn, Some(default_backup_path(PRE_RESTORE_PREFIX)))
119        .await
120        .context("creating pre-restore safety backup")?;
121    info!("Saved pre-restore backup to {}", safety.display());
122
123    // Close the live database so SQLite flushes and releases the WAL/SHM files.
124    drop(conn);
125    drop(db);
126
127    // Stage into a temp file in the same directory, then atomically rename over
128    // the live DB so a crash mid-copy can never leave a truncated primary.
129    let tmp = db_path.with_extension("restore-tmp");
130    std::fs::copy(source, &tmp)
131        .with_context(|| format!("staging snapshot at {}", tmp.display()))?;
132    restrict_permissions(&tmp, 0o600)?;
133    std::fs::rename(&tmp, &db_path)
134        .with_context(|| format!("swapping restored DB into {}", db_path.display()))?;
135    remove_sidecars(&db_path)?;
136
137    info!("Restored {} from {}", db_path.display(), source.display());
138    Ok(())
139}
140
141/// Open `source` and confirm it carries the expected Miku schema and passes
142/// an integrity check. Rejects a wrong-file or corrupt-file mistake before any
143/// destructive step.
144async fn validate_snapshot(source: &Path) -> Result<()> {
145    let db = Builder::new_local(source).build().await?;
146    let conn = db.connect()?;
147    for table in REQUIRED_TABLES {
148        let mut rows = conn.query(SQL_TABLE_EXISTS, libsql::params![table]).await?;
149        if rows.next().await?.is_none() {
150            bail!("missing expected table `{table}`");
151        }
152    }
153    check_integrity(&conn).await?;
154    Ok(())
155}
156
157/// Run `PRAGMA integrity_check` against the database at `path`.
158async fn assert_integrity(path: &Path) -> Result<()> {
159    let db = Builder::new_local(path).build().await?;
160    let conn = db.connect()?;
161    check_integrity(&conn).await
162}
163
164/// `PRAGMA integrity_check` returns a single `ok` row when the database is sound,
165/// or one row per problem otherwise.
166///
167/// libSQL's vector extension keeps an internal `libsql_vector_meta_shadow` table
168/// whose physical row order `VACUUM INTO` does not preserve, so `integrity_check`
169/// emits a benign `row not in PRIMARY KEY order for libsql_vector_meta_shadow`
170/// notice on every snapshot. That shadow table is a rebuildable index, not graph
171/// data, so we tolerate notices that reference it and fail only on anything else.
172async fn check_integrity(conn: &Connection) -> Result<()> {
173    let mut rows = conn.query(SQL_INTEGRITY_CHECK, ()).await?;
174    let mut problems = Vec::new();
175    while let Some(row) = rows.next().await? {
176        let line: String = row.get(0)?;
177        if line == "ok" || line.contains("libsql_vector") {
178            continue;
179        }
180        problems.push(line);
181    }
182    if !problems.is_empty() {
183        bail!("integrity check failed: {}", problems.join("; "));
184    }
185    Ok(())
186}
187
188/// Delete all but the newest `keep` snapshots with `prefix` in the managed
189/// directory. Best-effort: retention failures are logged, never fatal — a failed
190/// prune must not fail the backup that just succeeded.
191fn prune_backups(prefix: &str, keep: usize) {
192    let dir = backups_dir();
193    let Ok(entries) = std::fs::read_dir(&dir) else {
194        return;
195    };
196    let needle = format!("{prefix}-");
197    let mut snaps: Vec<PathBuf> = entries
198        .filter_map(|e| e.ok().map(|e| e.path()))
199        .filter(|p| {
200            p.file_name()
201                .and_then(|n| n.to_str())
202                .is_some_and(|n| n.starts_with(&needle) && n.ends_with(".db"))
203        })
204        .collect();
205    if snaps.len() <= keep {
206        return;
207    }
208    // Timestamp format sorts lexicographically == chronologically.
209    snaps.sort();
210    for old in &snaps[..snaps.len() - keep] {
211        if let Err(e) = std::fs::remove_file(old) {
212            warn!("could not prune old backup {}: {e}", old.display());
213        }
214    }
215}
216
217fn confirm_restore(db_path: &Path, source: &Path) -> Result<bool> {
218    use std::io::Write;
219    print!(
220        "Replace the live database at {} with {}? [y/N]: ",
221        db_path.display(),
222        source.display()
223    );
224    std::io::stdout().flush()?;
225    let mut input = String::new();
226    std::io::stdin().read_line(&mut input)?;
227    Ok(input.trim().eq_ignore_ascii_case("y"))
228}
229
230/// Delete `<db>-wal` and `<db>-shm` if present.
231fn remove_sidecars(db_path: &Path) -> Result<()> {
232    for suffix in ["-wal", "-shm"] {
233        let mut raw = db_path.as_os_str().to_owned();
234        raw.push(suffix);
235        let sidecar = PathBuf::from(raw);
236        if sidecar.exists() {
237            std::fs::remove_file(&sidecar)
238                .with_context(|| format!("removing stale sidecar {}", sidecar.display()))?;
239        }
240    }
241    Ok(())
242}
243
244/// Tighten file/dir permissions on Unix; a no-op elsewhere.
245#[cfg(unix)]
246fn restrict_permissions(path: &Path, mode: u32) -> Result<()> {
247    use std::os::unix::fs::PermissionsExt;
248    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
249        .with_context(|| format!("setting permissions on {}", path.display()))
250}
251
252#[cfg(not(unix))]
253fn restrict_permissions(_path: &Path, _mode: u32) -> Result<()> {
254    Ok(())
255}
256
257/// Exclusive advisory lock guarding a backup/restore against concurrent runs.
258/// Created with `O_EXCL`; removed on drop. A crash leaves a stale lock that the
259/// next run reports with a path so it can be cleared manually.
260struct OperationLock(PathBuf);
261
262impl OperationLock {
263    fn acquire(db_path: &Path) -> Result<Self> {
264        let mut raw = db_path.as_os_str().to_owned();
265        raw.push(".lock");
266        let lock_path = PathBuf::from(raw);
267        if let Some(parent) = lock_path.parent() {
268            std::fs::create_dir_all(parent).ok();
269        }
270        match std::fs::OpenOptions::new()
271            .write(true)
272            .create_new(true)
273            .open(&lock_path)
274        {
275            Ok(_) => Ok(Self(lock_path)),
276            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
277                "another backup/restore is in progress (lock: {}). \
278                 If no miku process is running, delete that file and retry.",
279                lock_path.display()
280            ),
281            Err(e) => Err(e).with_context(|| format!("creating lock file {}", lock_path.display())),
282        }
283    }
284}
285
286impl Drop for OperationLock {
287    fn drop(&mut self) {
288        let _ = std::fs::remove_file(&self.0);
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::mcp::EntityInput;
296    use tempfile::tempdir;
297
298    fn set_db(path: &Path) {
299        unsafe { std::env::set_var(ENV_DATABASE_URL, path.to_str().unwrap()) };
300    }
301
302    async fn seed(conn: &Connection, name: &str) {
303        crate::db::mcp_create_entities(
304            conn,
305            vec![EntityInput {
306                name: name.to_string(),
307                entity_type: "project".to_string(),
308                observations: vec!["obs".to_string()],
309            }],
310        )
311        .await
312        .unwrap();
313    }
314
315    #[tokio::test]
316    async fn backup_then_restore_roundtrip() {
317        let dir = tempdir().unwrap();
318        let db_file = dir.path().join("rt.db");
319        let snap = dir.path().join("snap.db");
320        set_db(&db_file);
321
322        let (db, conn) = crate::db::init_db().await.unwrap();
323        seed(&conn, "alpha").await;
324
325        backup(&conn, Some(snap.clone()), 3).await.unwrap();
326        assert!(snap.exists());
327
328        // Mutate after the backup so a successful restore is observable.
329        crate::db::mcp_reset(&conn).await.unwrap();
330
331        restore(db, conn, &snap, true).await.unwrap();
332
333        let (_db, conn) = crate::db::init_db().await.unwrap();
334        let graph = crate::db::mcp_open_nodes(&conn, vec!["alpha".to_string()])
335            .await
336            .unwrap();
337        assert_eq!(graph.entities.len(), 1, "restore did not bring back alpha");
338        assert_eq!(graph.entities[0].name, "alpha");
339    }
340
341    #[tokio::test]
342    async fn backup_refuses_existing_output() {
343        let dir = tempdir().unwrap();
344        let db_file = dir.path().join("b.db");
345        let snap = dir.path().join("snap.db");
346        set_db(&db_file);
347
348        let (_db, conn) = crate::db::init_db().await.unwrap();
349        backup(&conn, Some(snap.clone()), 3).await.unwrap();
350
351        let err = backup(&conn, Some(snap), 3).await.unwrap_err();
352        assert!(err.to_string().contains("already exists"));
353    }
354
355    #[tokio::test]
356    async fn restore_rejects_non_miku_file() {
357        let dir = tempdir().unwrap();
358        let db_file = dir.path().join("r.db");
359        let bogus = dir.path().join("bogus.db");
360        std::fs::write(&bogus, b"not a database").unwrap();
361        set_db(&db_file);
362
363        let (db, conn) = crate::db::init_db().await.unwrap();
364        let err = restore(db, conn, &bogus, true).await.unwrap_err();
365        assert!(err.to_string().contains("not a valid Miku database"));
366    }
367
368    #[tokio::test]
369    async fn restore_clears_stale_wal_sidecars() {
370        let dir = tempdir().unwrap();
371        let db_file = dir.path().join("w.db");
372        let snap = dir.path().join("snap.db");
373        set_db(&db_file);
374
375        let (db, conn) = crate::db::init_db().await.unwrap();
376        seed(&conn, "alpha").await;
377        backup(&conn, Some(snap.clone()), 3).await.unwrap();
378
379        // Plant stale sidecars that must not survive the restore.
380        let wal = PathBuf::from(format!("{}-wal", db_file.display()));
381        let shm = PathBuf::from(format!("{}-shm", db_file.display()));
382        std::fs::write(&wal, b"stale").unwrap();
383        std::fs::write(&shm, b"stale").unwrap();
384
385        restore(db, conn, &snap, true).await.unwrap();
386
387        assert!(!wal.exists(), "stale -wal sidecar survived restore");
388        assert!(!shm.exists(), "stale -shm sidecar survived restore");
389    }
390
391    #[tokio::test]
392    async fn default_backup_prunes_to_keep() {
393        let dir = tempdir().unwrap();
394        let db_file = dir.path().join("p.db");
395        set_db(&db_file);
396
397        // Pre-populate the managed dir with old snapshots, then prune to 2.
398        let bdir = backups_dir();
399        std::fs::create_dir_all(&bdir).unwrap();
400        for name in ["miku-20200101-000000000.db", "miku-20200102-000000000.db"] {
401            std::fs::write(bdir.join(name), b"old").unwrap();
402        }
403
404        let (_db, conn) = crate::db::init_db().await.unwrap();
405        // Default-location backup (output None) triggers retention.
406        backup(&conn, None, 2).await.unwrap();
407
408        let mut remaining: Vec<String> = std::fs::read_dir(&bdir)
409            .unwrap()
410            .filter_map(|e| e.ok())
411            .map(|e| e.file_name().into_string().unwrap())
412            .filter(|n| n.starts_with("miku-"))
413            .collect();
414        remaining.sort();
415        assert_eq!(remaining.len(), 2, "retention should keep exactly 2");
416        // The freshly written snapshot (newest) must be among the survivors.
417        assert!(
418            remaining
419                .iter()
420                .any(|n| n.as_str() > "miku-20200102-000000000.db")
421        );
422    }
423
424    #[cfg(unix)]
425    #[tokio::test]
426    async fn backup_file_is_owner_only() {
427        use std::os::unix::fs::PermissionsExt;
428        let dir = tempdir().unwrap();
429        let db_file = dir.path().join("perm.db");
430        let snap = dir.path().join("snap.db");
431        set_db(&db_file);
432
433        let (_db, conn) = crate::db::init_db().await.unwrap();
434        backup(&conn, Some(snap.clone()), 3).await.unwrap();
435
436        let mode = std::fs::metadata(&snap).unwrap().permissions().mode();
437        assert_eq!(mode & 0o777, 0o600, "snapshot must be owner-only");
438    }
439
440    #[test]
441    fn lock_is_exclusive_and_released() {
442        let dir = tempdir().unwrap();
443        let db_file = dir.path().join("lock.db");
444
445        let guard = OperationLock::acquire(&db_file).unwrap();
446        assert!(
447            OperationLock::acquire(&db_file).is_err(),
448            "second lock should be refused"
449        );
450        drop(guard);
451        assert!(
452            OperationLock::acquire(&db_file).is_ok(),
453            "lock should be re-acquirable after release"
454        );
455    }
456}