@@ -0,0 +1,143 @@
1+//! Binary dump/restore of a knot database as a single portable file.
3+//! `dump` uses SQLite's `VACUUM INTO`, which produces a consistent,
4+//! self-contained copy of a *live* database (WAL content is checkpointed into
5+//! the copy; no `-wal`/`-shm` sidecars). The result is itself a valid SQLite
6+//! database — so `restore` is just copying that file over the target, atomically
7+//! (write to a temp sibling, then rename) so an interrupted restore never
8+//! leaves a half-written db.
10+use rusqlite::Connection;
13+/// Copy the database at `src` to a single binary file at `dest`, consistent
14+/// even while `src` is live. Safe to call on a database another process is
15+/// writing: `VACUUM INTO` runs under the source's own locking.
17+/// `dest` must not already exist (SQLite refuses to overwrite), so a stale
18+/// copy is removed first only if it's a file we're replacing wholesale.
19+pub fn dump_db(src: &Path, dest: &Path) -> Result<(), String> {
21+ return Err(format!("no database at {}", src.display()));
23+ // SQLite requires the VACUUM INTO target to not exist. Remove a previous
24+ // dump so re-dumping to the same path works.
26+ std::fs::remove_file(dest)
27+ .map_err(|e| format!("cannot replace existing dump at {}: {}", dest.display(), e))?;
29+ let conn = Connection::open(src).map_err(|e| format!("cannot open {}: {}", src.display(), e))?;
32+ .ok_or_else(|| format!("destination path is not valid UTF-8: {}", dest.display()))?;
33+ // Escape single quotes for the SQL string literal.
34+ let escaped = dest_str.replace('\'', "''");
35+ conn.execute_batch(&format!("VACUUM INTO '{}';", escaped))
36+ .map_err(|e| format!("dump failed: {}", e))
39+/// Restore a database: copy a dump file (produced by [`dump_db`]) over `dest`
40+/// atomically. The dump is a complete SQLite database, so restore is a file
41+/// copy — done via a temp sibling + rename so a crash mid-restore leaves the
42+/// old `dest` intact rather than a truncated one.
44+/// The target must not be in use: this replaces the file, which a live writer
45+/// would not expect. (Dump is live-safe; restore is an offline operation.)
46+pub fn restore_db(dump: &Path, dest: &Path) -> Result<(), String> {
48+ return Err(format!("no dump file at {}", dump.display()));
50+ // Sanity-check the dump is a SQLite db before overwriting anything.
51+ Connection::open(dump)
52+ .and_then(|c| c.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())))
53+ .map_err(|e| format!("{} is not a valid database: {}", dump.display(), e))?;
57+ .ok_or_else(|| format!("destination has no parent directory: {}", dest.display()))?;
58+ let tmp = parent.join(format!(
59+ ".{}.knot-restore-tmp",
61+ .and_then(|n| n.to_str())
64+ std::fs::copy(dump, &tmp).map_err(|e| format!("failed to stage restore: {}", e))?;
65+ std::fs::rename(&tmp, dest).map_err(|e| {
66+ let _ = std::fs::remove_file(&tmp);
67+ format!("failed to move restored db into place: {}", e)
75+ fn fresh(dir: &str, name: &str) -> std::path::PathBuf {
76+ let d = std::env::temp_dir().join(format!("knot_dbdump_{}_{}", dir, std::process::id()));
77+ std::fs::create_dir_all(&d).unwrap();
81+ /// Build a small db with a known row.
82+ fn make_db(path: &Path) {
83+ let c = Connection::open(path).unwrap();
84+ c.execute_batch("CREATE TABLE t (name TEXT, n INTEGER); INSERT INTO t VALUES ('a', 1), ('b', 2);").unwrap();
87+ fn row_count(path: &Path) -> i64 {
88+ Connection::open(path)
90+ .query_row("SELECT count(*) FROM t", [], |r| r.get(0))
95+ fn dump_then_restore_roundtrips_data() {
96+ let src = fresh("rt", "src.db");
97+ let dump = fresh("rt", "out.dump");
98+ let restored = fresh("rt", "restored.db");
100+ dump_db(&src, &dump).unwrap();
101+ assert!(dump.exists());
102+ restore_db(&dump, &restored).unwrap();
103+ assert_eq!(row_count(&restored), 2);
107+ fn dump_is_a_valid_single_file_db() {
108+ let src = fresh("valid", "src.db");
109+ let dump = fresh("valid", "out.dump");
111+ dump_db(&src, &dump).unwrap();
112+ // The dump opens directly as a database and holds the data.
113+ assert_eq!(row_count(&dump), 2);
114+ // No WAL/SHM sidecars: one self-contained file.
115+ assert!(!dump.with_extension("dump-wal").exists());
116+ assert!(!dump.with_extension("dump-shm").exists());
120+ fn dump_missing_source_errors() {
121+ let r = dump_db(&fresh("err", "nope.db"), &fresh("err", "out.dump"));
122+ assert!(r.is_err());
126+ fn restore_rejects_a_non_database() {
127+ let bad = fresh("bad", "bad.dump");
128+ std::fs::write(&bad, b"not a database").unwrap();
129+ let r = restore_db(&bad, &fresh("bad", "out.db"));
130+ assert!(r.is_err());
134+ fn redump_replaces_existing_dump() {
135+ let src = fresh("redump", "src.db");
136+ let dump = fresh("redump", "out.dump");
138+ dump_db(&src, &dump).unwrap();
139+ // Second dump to the same path must succeed (not "target exists").
140+ dump_db(&src, &dump).unwrap();
141+ assert_eq!(row_count(&dump), 2);