1use 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
26const REQUIRED_TABLES: [&str; 2] = ["mcp_entities", "topics"];
28const BACKUP_PREFIX: &str = "miku";
30const PRE_RESTORE_PREFIX: &str = "pre-restore";
32
33pub 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
42fn backups_dir() -> PathBuf {
45 effective_db_path()
46 .parent()
47 .unwrap_or_else(|| Path::new("."))
48 .join("backups")
49}
50
51fn 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
58pub 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
72async 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
99pub 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 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 drop(conn);
125 drop(db);
126
127 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
141async 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
157async 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
164async 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
188fn 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 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
230fn 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#[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
257struct 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 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 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 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 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 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}