use std::path::{Path, PathBuf};
use rusqlite::Connection;
use solo_core::{Error, Result, TenantId};
use crate::audit::{AuditOperation, AuditResult, insert_audit_admin_row};
use crate::backup::backup_database;
use crate::init::open_sqlcipher;
use crate::key_material::KeyMaterial;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupReport {
pub path: PathBuf,
pub bytes_written: u64,
pub integrity_ok: bool,
pub audit_admin_row_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoreReport {
pub from: PathBuf,
pub bytes_restored: u64,
pub audit_admin_row_id: i64,
}
pub fn backup_tenant(
tenant_id: &TenantId,
db_path: &Path,
out: &Path,
key: &KeyMaterial,
data_dir: &Path,
) -> Result<BackupReport> {
if !out.is_dir() {
return Err(Error::invalid_input(format!(
"backup output directory does not exist: {}",
out.display()
)));
}
if !db_path.is_file() {
return Err(Error::not_found(format!(
"tenant DB to back up not found: {}",
db_path.display()
)));
}
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let filename = format!(".solo-backup.{tenant_id}.{stamp}.db");
let target = out.join(&filename);
if crate::backup::paths_refer_to_same_file(db_path, &target) {
return Err(Error::invalid_input(format!(
"backup target {} resolves to the source DB; refusing",
target.display()
)));
}
if target.exists() {
return Err(Error::conflict(format!(
"backup target {} already exists; choose a different out dir or remove the file first",
target.display()
)));
}
backup_database(db_path, &target, key)?;
let verify_conn = open_sqlcipher(&target, key)?;
verify_integrity(&verify_conn).inspect_err(|_e| {
let _ = std::fs::remove_file(&target);
})?;
drop(verify_conn);
let bytes_written = std::fs::metadata(&target)
.map_err(|e| Error::storage(format!("stat backup file {}: {e}", target.display())))?
.len();
let now_ms = chrono::Utc::now().timestamp_millis();
let admin_path = data_dir.join(crate::tenants::TENANTS_INDEX_FILENAME);
let admin_conn = open_sqlcipher(&admin_path, key)?;
let details = serde_json::json!({
"path": target.display().to_string(),
"bytes": bytes_written,
});
let audit_admin_row_id = insert_audit_admin_row(
&admin_conn,
now_ms,
None,
AuditOperation::TenantBackup,
Some(tenant_id.as_str()),
AuditResult::Ok,
Some(&details),
)?;
Ok(BackupReport {
path: target,
bytes_written,
integrity_ok: true,
audit_admin_row_id,
})
}
pub fn restore_tenant(
tenant_id: &TenantId,
from: &Path,
dest_db_path: &Path,
key: &KeyMaterial,
data_dir: &Path,
force: bool,
) -> Result<RestoreReport> {
if !from.is_file() {
return Err(Error::not_found(format!(
"restore source not found: {}",
from.display()
)));
}
let src_conn = open_sqlcipher(from, key).map_err(|_| {
Error::invalid_input(format!(
"restore: source {} fails to decrypt under the destination tenant's key; \
refusing to restore",
from.display()
))
})?;
verify_integrity(&src_conn)?;
drop(src_conn);
if dest_db_path.exists() && !force {
return Err(Error::conflict(format!(
"destination {} exists; pass --confirm to overwrite",
dest_db_path.display()
)));
}
let staging = staging_path(dest_db_path);
if staging.exists() {
std::fs::remove_file(&staging).map_err(|e| {
Error::storage(format!(
"remove pre-existing staging file {}: {e}",
staging.display()
))
})?;
}
std::fs::copy(from, &staging).map_err(|e| {
Error::storage(format!(
"copy {} → {}: {e}",
from.display(),
staging.display()
))
})?;
{
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&staging)
.map_err(|e| Error::storage(format!("open staging for fsync: {e}")))?;
f.sync_all()
.map_err(|e| Error::storage(format!("fsync staging: {e}")))?;
}
#[cfg(windows)]
{
if dest_db_path.exists() {
std::fs::remove_file(dest_db_path).map_err(|e| {
Error::storage(format!(
"remove dest {} before swap: {e}",
dest_db_path.display()
))
})?;
}
}
std::fs::rename(&staging, dest_db_path).map_err(|e| {
Error::storage(format!(
"rename {} → {}: {e}",
staging.display(),
dest_db_path.display()
))
})?;
let bytes_restored = std::fs::metadata(dest_db_path)
.map_err(|e| Error::storage(format!("stat dest after restore: {e}")))?
.len();
let now_ms = chrono::Utc::now().timestamp_millis();
let admin_path = data_dir.join(crate::tenants::TENANTS_INDEX_FILENAME);
let admin_conn = open_sqlcipher(&admin_path, key)?;
let details = serde_json::json!({
"from": from.display().to_string(),
"bytes_restored": bytes_restored,
});
let audit_admin_row_id = insert_audit_admin_row(
&admin_conn,
now_ms,
None,
AuditOperation::TenantRestore,
Some(tenant_id.as_str()),
AuditResult::Ok,
Some(&details),
)?;
Ok(RestoreReport {
from: from.to_path_buf(),
bytes_restored,
audit_admin_row_id,
})
}
fn verify_integrity(conn: &Connection) -> Result<()> {
let result: String = conn
.query_row("PRAGMA integrity_check", [], |r| r.get(0))
.map_err(|e| Error::storage(format!("PRAGMA integrity_check: {e}")))?;
if result != "ok" {
return Err(Error::storage(format!(
"integrity_check failed: {result}"
)));
}
Ok(())
}
fn staging_path(dest: &Path) -> PathBuf {
let mut fname = dest
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_default();
fname.push(".new");
match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(fname),
_ => PathBuf::from(fname),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staging_path_is_sibling_of_dest() {
use std::path::PathBuf;
let dest = PathBuf::from("C:/data/tenants/default.db");
let staged = staging_path(&dest);
let staged_str = staged.to_string_lossy().replace('\\', "/");
assert!(
staged_str.ends_with("default.db.new"),
"got `{}`",
staged_str
);
assert_eq!(staged.parent(), dest.parent());
}
#[test]
fn verify_integrity_accepts_fresh_in_memory_db() {
let conn = Connection::open_in_memory().unwrap();
verify_integrity(&conn).expect("empty DB must be integrity-ok");
}
#[test]
fn backup_restore_round_trip_preserves_user_rows() {
use crate::init::{InitParams, init};
use rusqlite::params;
use zeroize::Zeroizing;
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let out_dir = tmp.path().join("backups");
std::fs::create_dir_all(&out_dir).unwrap();
let pass = "round-trip backup test";
let outcome = init(InitParams {
data_dir: data_dir.clone(),
passphrase: Zeroizing::new(pass.into()),
force: false,
embedder: crate::init::default_embedder(),
})
.unwrap();
let cfg = crate::config::SoloConfig::read(&outcome.config_path).unwrap();
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive(pass, &salt).unwrap();
{
let conn = crate::init::open_sqlcipher(&outcome.db_path, &key).unwrap();
let now = chrono::Utc::now().timestamp_millis();
conn.execute(
"INSERT INTO episodes (
memory_id, ts_ms, source_type, content,
encoding_context_json, confidence, strength, salience,
tier, created_at_ms, updated_at_ms
) VALUES (?, ?, 'user_message', 'sentinel', '{}', 0.9, 0.5, 0.5,
'hot', ?, ?)",
params!["01900000-0000-7000-8000-000000000001", now, now, now],
)
.unwrap();
}
let tenant_id = TenantId::default_tenant();
let report = backup_tenant(
&tenant_id,
&outcome.db_path,
&out_dir,
&key,
&data_dir,
)
.expect("backup_tenant");
assert!(report.integrity_ok);
assert!(report.path.is_file());
let hash_before: String = {
let conn = crate::init::open_sqlcipher(&outcome.db_path, &key).unwrap();
conn.query_row(
"SELECT content FROM episodes WHERE memory_id = ?",
params!["01900000-0000-7000-8000-000000000001"],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(hash_before, "sentinel");
std::fs::remove_file(&outcome.db_path).unwrap();
let restore_report = restore_tenant(
&tenant_id,
&report.path,
&outcome.db_path,
&key,
&data_dir,
false,
)
.expect("restore_tenant");
assert!(restore_report.bytes_restored > 0);
let hash_after: String = {
let conn = crate::init::open_sqlcipher(&outcome.db_path, &key).unwrap();
conn.query_row(
"SELECT content FROM episodes WHERE memory_id = ?",
params!["01900000-0000-7000-8000-000000000001"],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(hash_after, hash_before);
let admin = crate::init::open_sqlcipher(
&data_dir.join(crate::tenants::TENANTS_INDEX_FILENAME),
&key,
)
.unwrap();
let n_backup: i64 = admin
.query_row(
"SELECT COUNT(*) FROM audit_events_admin WHERE operation = 'tenant.backup'",
[],
|r| r.get(0),
)
.unwrap();
let n_restore: i64 = admin
.query_row(
"SELECT COUNT(*) FROM audit_events_admin WHERE operation = 'tenant.restore'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n_backup, 1);
assert_eq!(n_restore, 1);
}
#[test]
fn restore_refuses_wrong_key() {
use crate::init::{InitParams, init};
use zeroize::Zeroizing;
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let out_dir = tmp.path().join("backups");
std::fs::create_dir_all(&out_dir).unwrap();
let pass = "right passphrase";
let outcome = init(InitParams {
data_dir: data_dir.clone(),
passphrase: Zeroizing::new(pass.into()),
force: false,
embedder: crate::init::default_embedder(),
})
.unwrap();
let cfg = crate::config::SoloConfig::read(&outcome.config_path).unwrap();
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive(pass, &salt).unwrap();
let tenant_id = TenantId::default_tenant();
let report = backup_tenant(
&tenant_id,
&outcome.db_path,
&out_dir,
&key,
&data_dir,
)
.unwrap();
let wrong_key = KeyMaterial::derive("WRONG PASSPHRASE", &salt).unwrap();
let err = restore_tenant(
&tenant_id,
&report.path,
&outcome.db_path,
&wrong_key,
&data_dir,
true,
)
.expect_err("wrong key must refuse");
let msg = err.to_string();
assert!(
msg.contains("fails to decrypt") || msg.contains("key"),
"got `{msg}`"
);
}
#[test]
fn restore_refuses_existing_destination_without_confirm() {
use crate::init::{InitParams, init};
use zeroize::Zeroizing;
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let out_dir = tmp.path().join("backups");
std::fs::create_dir_all(&out_dir).unwrap();
let pass = "existing-dest test";
let outcome = init(InitParams {
data_dir: data_dir.clone(),
passphrase: Zeroizing::new(pass.into()),
force: false,
embedder: crate::init::default_embedder(),
})
.unwrap();
let cfg = crate::config::SoloConfig::read(&outcome.config_path).unwrap();
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive(pass, &salt).unwrap();
let tenant_id = TenantId::default_tenant();
let report = backup_tenant(
&tenant_id,
&outcome.db_path,
&out_dir,
&key,
&data_dir,
)
.unwrap();
let err = restore_tenant(
&tenant_id,
&report.path,
&outcome.db_path,
&key,
&data_dir,
false, )
.expect_err("existing dest without confirm must refuse");
let msg = err.to_string();
assert!(msg.contains("destination") && msg.contains("exists"), "got `{msg}`");
let r = restore_tenant(
&tenant_id,
&report.path,
&outcome.db_path,
&key,
&data_dir,
true,
)
.expect("existing dest with confirm must succeed");
assert!(r.bytes_restored > 0);
}
#[test]
fn backup_to_missing_out_dir_errors_cleanly() {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().to_path_buf();
let nonexistent = tmp.path().join("does-not-exist");
let db_path = tmp.path().join("source.db");
std::fs::write(&db_path, b"placeholder").unwrap();
let tenant_id = TenantId::new("test").unwrap();
let key = KeyMaterial::derive("p", &[0u8; 16]).unwrap();
let err = backup_tenant(&tenant_id, &db_path, &nonexistent, &key, &data_dir)
.expect_err("missing out dir must error");
let msg = err.to_string();
assert!(msg.contains("does not exist"), "got `{msg}`");
}
#[test]
fn restore_refuses_missing_source() {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().to_path_buf();
let from = tmp.path().join("does-not-exist.db");
let dest = tmp.path().join("dest.db");
let tenant_id = TenantId::new("test").unwrap();
let key = KeyMaterial::derive("p", &[0u8; 16]).unwrap();
let err = restore_tenant(&tenant_id, &from, &dest, &key, &data_dir, false)
.expect_err("missing source must error");
let msg = err.to_string();
assert!(msg.contains("not found"), "got `{msg}`");
}
}