use std::path::Path;
use rusqlite::Connection;
use rusqlite::backup::Backup;
use crate::init::open_sqlcipher;
use crate::key_material::KeyMaterial;
use solo_core::{Error, Result};
pub const DEFAULT_BACKUP_PAGES_PER_STEP: i32 = 100;
pub fn backup_database(
src_path: &Path,
dest_path: &Path,
key: &KeyMaterial,
) -> Result<()> {
let src = open_sqlcipher(src_path, key)?;
let result = backup_from_connection(&src, dest_path, key);
if let Err((_, e)) = src.close() {
return Err(Error::storage(format!("close source after backup: {e}")));
}
result
}
pub fn paths_refer_to_same_file(src: &Path, dest: &Path) -> bool {
let src_canon = match std::fs::canonicalize(src) {
Ok(p) => p,
Err(_) => return false,
};
let dest_parent = match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let (Ok(dest_parent_canon), Some(dest_file)) =
(std::fs::canonicalize(dest_parent), dest.file_name())
else {
return false;
};
let dest_canon = dest_parent_canon.join(dest_file);
src_canon == dest_canon
}
pub fn backup_from_connection(
src: &Connection,
dest_path: &Path,
key: &KeyMaterial,
) -> Result<()> {
if let Some(src_str) = src.path() {
if paths_refer_to_same_file(Path::new(src_str), dest_path) {
return Err(Error::invalid_input(format!(
"backup destination {} is the same file as the source database; \
refusing to overwrite (would corrupt the live database)",
dest_path.display()
)));
}
}
let mut dst = Connection::open(dest_path).map_err(|e| {
Error::storage(format!(
"open backup destination {}: {e}",
dest_path.display()
))
})?;
let key_pragma: zeroize::Zeroizing<String> = {
let hex = key.as_hex();
zeroize::Zeroizing::new(format!("PRAGMA key = \"x'{}'\"", &*hex))
};
dst.execute_batch(&key_pragma)
.map_err(|e| Error::storage(format!("PRAGMA key on backup destination: {e}")))?;
let backup = Backup::new(src, &mut dst)
.map_err(|e| Error::storage(format!("Backup::new: {e}")))?;
backup
.run_to_completion(
DEFAULT_BACKUP_PAGES_PER_STEP,
std::time::Duration::from_millis(0),
None,
)
.map_err(|e| Error::storage(format!("Backup::run_to_completion: {e}")))?;
drop(backup);
dst.close()
.map_err(|(_, e)| Error::storage(format!("close destination after backup: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{EmbedderConfig, SoloConfig};
use crate::init::{InitParams, init};
use tempfile::TempDir;
use zeroize::Zeroizing;
fn fresh_init(dir: &Path, passphrase: &str) -> SoloConfig {
let outcome = init(InitParams {
data_dir: dir.to_path_buf(),
passphrase: Zeroizing::new(passphrase.to_string()),
force: false,
embedder: EmbedderConfig {
name: "test-embedder".into(),
version: "v1".into(),
dim: 1024,
dtype: "f32".into(),
},
})
.expect("init");
SoloConfig::read(&outcome.config_path).expect("read config")
}
#[test]
#[ignore = "requires SQLCipher: under plain bundled SQLite, PRAGMA key is a no-op so wrong keys silently succeed. Run with the workspace's bundled-sqlcipher-vendored-openssl feature: `cargo test -p solo-storage -- --include-ignored`"]
fn backup_round_trip_preserves_database() {
let src_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let passphrase = "round-trip test passphrase";
let cfg = fresh_init(src_dir.path(), passphrase);
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive(passphrase, &salt).unwrap();
{
let conn = open_sqlcipher(&src_dir.path().join("solo.db"), &key).unwrap();
conn.execute(
"INSERT INTO episodes (memory_id, ts_ms, source_type, content,
encoding_context_json, status, tier,
confidence, strength, salience,
created_at_ms, updated_at_ms)
VALUES (?, ?, 'test', 'sentinel', '{}', 'active', 'hot',
0.9, 0.5, 0.5, ?, ?)",
rusqlite::params![
"01900000-0000-7000-8000-000000000001",
0i64,
0i64,
0i64
],
)
.expect("insert sentinel");
}
let dest_path = dest_dir.path().join("solo-backup.db");
backup_database(&src_dir.path().join("solo.db"), &dest_path, &key)
.expect("backup_database");
let dst = open_sqlcipher(&dest_path, &key).expect("open backup with same key");
let row_count: i64 = dst
.query_row(
"SELECT COUNT(*) FROM episodes WHERE memory_id = ?",
rusqlite::params!["01900000-0000-7000-8000-000000000001"],
|row| row.get(0),
)
.expect("query backup");
assert_eq!(row_count, 1, "sentinel row should be present in backup");
let bad_key = KeyMaterial::derive("WRONG PASSPHRASE", &salt).unwrap();
let bad_open = open_sqlcipher(&dest_path, &bad_key);
assert!(
bad_open.is_err(),
"opening backup with wrong key should fail"
);
}
#[test]
#[ignore = "requires SQLCipher (see backup_round_trip_preserves_database)"]
fn hot_backup_via_writer_round_trip() {
use crate::vector_index::HnswIndex;
use crate::writer::{WriterActor, WriterSpawn};
use crate::embedder::StubEmbedder;
use crate::embedder_registry::get_or_insert_embedder_id;
use std::sync::Arc;
let src_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let passphrase = "hot-backup test passphrase";
let cfg = fresh_init(src_dir.path(), passphrase);
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive(passphrase, &salt).unwrap();
{
let conn = open_sqlcipher(&src_dir.path().join("solo.db"), &key).unwrap();
conn.execute(
"INSERT INTO episodes (memory_id, ts_ms, source_type, content,
encoding_context_json, status, tier,
confidence, strength, salience,
created_at_ms, updated_at_ms)
VALUES (?, ?, 'test', 'hot-sentinel', '{}', 'active', 'hot',
0.9, 0.5, 0.5, ?, ?)",
rusqlite::params![
"01900000-0000-7000-8000-000000000002",
0i64,
0i64,
0i64
],
)
.unwrap();
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let conn = open_sqlcipher(&src_dir.path().join("solo.db"), &key).unwrap();
let mut conn_for_id = open_sqlcipher(&src_dir.path().join("solo.db"), &key).unwrap();
let identity = crate::embedder_registry::EmbedderIdentity {
name: cfg.embedder.name.clone(),
version: cfg.embedder.version.clone(),
dim: cfg.embedder.dim,
dtype: cfg.embedder.dtype.clone(),
};
let embedder_id = get_or_insert_embedder_id(&mut conn_for_id, &identity).unwrap();
drop(conn_for_id);
let hnsw = Arc::new(HnswIndex::new(
cfg.embedder.dim as usize,
crate::vector_index::HnswParams::default(),
));
let embedder: Arc<dyn solo_core::Embedder> = Arc::new(StubEmbedder::new(
&cfg.embedder.name,
&cfg.embedder.version,
cfg.embedder.dim as usize,
));
let WriterSpawn { handle, join } =
WriterActor::spawn_full_with_key_and_optional_steward(
conn,
hnsw,
src_dir.path().to_path_buf(),
embedder_id,
embedder,
None,
key.clone(),
);
let dest_path = dest_dir.path().join("solo-hot-backup.db");
handle.backup(dest_path.clone()).await.expect("hot backup");
drop(handle);
tokio::task::spawn_blocking(move || join.join().ok()).await.ok();
let dst = open_sqlcipher(&dest_path, &key).unwrap();
let n: i64 = dst
.query_row(
"SELECT COUNT(*) FROM episodes WHERE memory_id = ?",
rusqlite::params!["01900000-0000-7000-8000-000000000002"],
|row| row.get(0),
)
.unwrap();
assert_eq!(n, 1, "hot-backup sentinel should be present");
});
}
#[test]
#[ignore = "requires SQLCipher (see backup_round_trip_preserves_database)"]
fn backup_to_same_file_as_source_refused() {
let src_dir = TempDir::new().unwrap();
let passphrase = "same-file refusal test";
let cfg = fresh_init(src_dir.path(), passphrase);
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive(passphrase, &salt).unwrap();
let live_db = src_dir.path().join("solo.db");
let result = backup_database(&live_db, &live_db, &key);
let err = result.expect_err("must refuse same-file backup");
let msg = err.to_string();
assert!(
msg.contains("same file") && msg.contains("refusing"),
"error should explain why: got `{msg}`"
);
let live_db_alt = src_dir.path().join("./solo.db");
let result2 = backup_database(&live_db, &live_db_alt, &key);
assert!(
result2.is_err(),
"redundant ./ in dest path should still be caught"
);
}
#[test]
#[ignore = "requires SQLCipher (see backup_round_trip_preserves_database)"]
fn backup_with_wrong_source_key_fails() {
let src_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let passphrase = "real passphrase";
let cfg = fresh_init(src_dir.path(), passphrase);
let salt = cfg.salt_bytes().unwrap();
let wrong_key = KeyMaterial::derive("not the real one", &salt).unwrap();
let dest_path = dest_dir.path().join("solo-backup.db");
let result =
backup_database(&src_dir.path().join("solo.db"), &dest_path, &wrong_key);
assert!(
result.is_err(),
"backup with wrong source key should fail at open"
);
}
}