mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
c2b60623a6
Split LOCAL_ONLY_TABLES into SYNC_SKIP_TABLES (export) and SYNC_PRESERVE_TABLES (import). provider_health is skipped on export but no longer restored on import, since it has a foreign key on providers(id, app_type) that may not match the remote dataset. Health data is ephemeral and rebuilds automatically at runtime.
859 lines
32 KiB
Rust
859 lines
32 KiB
Rust
//! 数据库备份和恢复
|
||
//!
|
||
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
||
|
||
use super::{lock_conn, Database};
|
||
use crate::config::get_app_config_dir;
|
||
use crate::error::AppError;
|
||
use chrono::{Local, Utc};
|
||
use rusqlite::backup::Backup;
|
||
use rusqlite::types::ValueRef;
|
||
use rusqlite::Connection;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use tempfile::NamedTempFile;
|
||
|
||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||
|
||
/// Tables whose data rows are skipped when exporting for WebDAV sync.
|
||
const SYNC_SKIP_TABLES: &[&str] = &[
|
||
"proxy_request_logs",
|
||
"stream_check_logs",
|
||
"provider_health",
|
||
"proxy_live_backup",
|
||
"usage_daily_rollups",
|
||
];
|
||
|
||
/// Tables whose local data is preserved (restored from local snapshot) during WebDAV import.
|
||
/// Excludes ephemeral tables like provider_health that can safely rebuild at runtime.
|
||
const SYNC_PRESERVE_TABLES: &[&str] = &[
|
||
"proxy_request_logs",
|
||
"stream_check_logs",
|
||
"proxy_live_backup",
|
||
"usage_daily_rollups",
|
||
];
|
||
|
||
/// A database backup entry for the UI
|
||
#[derive(Debug, serde::Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct BackupEntry {
|
||
pub filename: String,
|
||
pub size_bytes: u64,
|
||
pub created_at: String, // ISO 8601
|
||
}
|
||
|
||
impl Database {
|
||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串,完整导出)
|
||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||
let snapshot = self.snapshot_to_memory()?;
|
||
Self::dump_sql(&snapshot, &[])
|
||
}
|
||
|
||
/// Export SQL for sync (WebDAV), skipping local-only tables' data
|
||
pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
|
||
let snapshot = self.snapshot_to_memory()?;
|
||
Self::dump_sql(&snapshot, SYNC_SKIP_TABLES)
|
||
}
|
||
|
||
/// 导出为 SQLite 兼容的 SQL 文本
|
||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||
let dump = self.export_sql_string()?;
|
||
|
||
if let Some(parent) = target_path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||
}
|
||
|
||
crate::config::atomic_write(target_path, dump.as_bytes())
|
||
}
|
||
|
||
/// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
|
||
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
|
||
if !source_path.exists() {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"SQL 文件不存在: {}",
|
||
source_path.display()
|
||
)));
|
||
}
|
||
|
||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||
self.import_sql_string(sql_content)
|
||
}
|
||
|
||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||
self.import_sql_string_inner(sql_raw, &[])
|
||
}
|
||
|
||
/// Import SQL generated for sync, then restore local-only tables from the
|
||
/// current device snapshot before replacing the main database.
|
||
pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
|
||
self.import_sql_string_inner(sql_raw, SYNC_PRESERVE_TABLES)
|
||
}
|
||
|
||
fn import_sql_string_inner(
|
||
&self,
|
||
sql_raw: &str,
|
||
preserve_tables: &[&str],
|
||
) -> Result<String, AppError> {
|
||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||
|
||
// 导入前备份现有数据库
|
||
let backup_path = self.backup_database_file()?;
|
||
|
||
let local_snapshot = if preserve_tables.is_empty() {
|
||
None
|
||
} else {
|
||
Some(self.snapshot_to_memory()?)
|
||
};
|
||
|
||
// 在临时数据库执行导入,确保失败不会污染主库
|
||
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
|
||
context: "创建临时数据库文件失败".to_string(),
|
||
source: e,
|
||
})?;
|
||
let temp_path = temp_file.path().to_path_buf();
|
||
let temp_conn =
|
||
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
temp_conn
|
||
.execute_batch(sql_content)
|
||
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
||
|
||
// 补齐缺失表/索引并进行基础校验
|
||
Self::create_tables_on_conn(&temp_conn)?;
|
||
Self::apply_schema_migrations_on_conn(&temp_conn)?;
|
||
Self::validate_basic_state(&temp_conn)?;
|
||
if let Some(local_snapshot) = local_snapshot.as_ref() {
|
||
Self::restore_tables(local_snapshot, &temp_conn, preserve_tables)?;
|
||
}
|
||
|
||
// 使用 Backup 将临时库原子写回主库
|
||
{
|
||
let mut main_conn = lock_conn!(self.conn);
|
||
let backup = Backup::new(&temp_conn, &mut main_conn)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
backup
|
||
.step(-1)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
let backup_id = backup_path
|
||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||
.unwrap_or_default();
|
||
|
||
Ok(backup_id)
|
||
}
|
||
|
||
/// 创建内存快照以避免长时间持有数据库锁
|
||
pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut snapshot =
|
||
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
{
|
||
let backup =
|
||
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
|
||
backup
|
||
.step(-1)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
|
||
let trimmed = sql.trim_start();
|
||
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
|
||
return Ok(());
|
||
}
|
||
|
||
Err(AppError::localized(
|
||
"backup.sql.invalid_format",
|
||
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
|
||
"Only SQL backups exported by CC Switch are supported.",
|
||
))
|
||
}
|
||
|
||
fn restore_tables(
|
||
source_conn: &Connection,
|
||
target_conn: &Connection,
|
||
tables: &[&str],
|
||
) -> Result<(), AppError> {
|
||
for table in tables {
|
||
if !Self::table_exists(source_conn, table)? || !Self::table_exists(target_conn, table)?
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let columns = Self::get_table_columns(source_conn, table)?;
|
||
if columns.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
target_conn
|
||
.execute(&format!("DELETE FROM \"{table}\""), [])
|
||
.map_err(|e| AppError::Database(format!("清空表 {table} 失败: {e}")))?;
|
||
|
||
let placeholders = (1..=columns.len())
|
||
.map(|idx| format!("?{idx}"))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let cols = columns
|
||
.iter()
|
||
.map(|column| format!("\"{column}\""))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let insert_sql = format!("INSERT INTO \"{table}\" ({cols}) VALUES ({placeholders})");
|
||
|
||
let mut stmt = source_conn
|
||
.prepare(&format!("SELECT * FROM \"{table}\""))
|
||
.map_err(|e| AppError::Database(format!("读取表 {table} 失败: {e}")))?;
|
||
let mut rows = stmt
|
||
.query([])
|
||
.map_err(|e| AppError::Database(format!("查询表 {table} 数据失败: {e}")))?;
|
||
|
||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
let mut values = Vec::with_capacity(columns.len());
|
||
for idx in 0..columns.len() {
|
||
values.push(
|
||
row.get::<_, rusqlite::types::Value>(idx)
|
||
.map_err(|e| AppError::Database(e.to_string()))?,
|
||
);
|
||
}
|
||
|
||
target_conn
|
||
.execute(&insert_sql, rusqlite::params_from_iter(values.iter()))
|
||
.map_err(|e| AppError::Database(format!("恢复表 {table} 数据失败: {e}")))?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||
if interval_hours > 0 {
|
||
let backup_dir = get_app_config_dir().join("backups");
|
||
if !backup_dir.exists() {
|
||
self.backup_database_file()?;
|
||
} else {
|
||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||
entries
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||
.max()
|
||
});
|
||
|
||
let interval_secs = u64::from(interval_hours) * 3600;
|
||
let needs_backup = match latest {
|
||
None => true,
|
||
Some(last_modified) => {
|
||
last_modified.elapsed().unwrap_or_default()
|
||
> std::time::Duration::from_secs(interval_secs)
|
||
}
|
||
};
|
||
|
||
if needs_backup {
|
||
log::info!(
|
||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||
);
|
||
self.backup_database_file()?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Periodic maintenance is always enabled, regardless of auto-backup settings.
|
||
let mut reclaimed_rows = 0u64;
|
||
match self.cleanup_old_stream_check_logs(7) {
|
||
Ok(deleted) => {
|
||
reclaimed_rows += deleted;
|
||
}
|
||
Err(e) => {
|
||
log::warn!("Periodic stream_check_logs cleanup failed: {e}");
|
||
}
|
||
}
|
||
match self.rollup_and_prune(30) {
|
||
Ok(deleted) => {
|
||
reclaimed_rows += deleted;
|
||
}
|
||
Err(e) => {
|
||
log::warn!("Periodic rollup_and_prune failed: {e}");
|
||
}
|
||
}
|
||
if reclaimed_rows > 0 {
|
||
let conn = lock_conn!(self.conn);
|
||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||
log::warn!("Periodic incremental vacuum failed: {e}");
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||
if !db_path.exists() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let backup_dir = db_path
|
||
.parent()
|
||
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
|
||
.join("backups");
|
||
|
||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||
|
||
let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S"));
|
||
let mut backup_id = base_id.clone();
|
||
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||
let mut counter = 1;
|
||
while backup_path.exists() {
|
||
backup_id = format!("{base_id}_{counter}");
|
||
backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||
counter += 1;
|
||
}
|
||
|
||
{
|
||
let conn = lock_conn!(self.conn);
|
||
let mut dest_conn =
|
||
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||
let backup = Backup::new(&conn, &mut dest_conn)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
backup
|
||
.step(-1)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
Self::cleanup_db_backups(&backup_dir)?;
|
||
Ok(Some(backup_path))
|
||
}
|
||
|
||
/// 清理旧的数据库备份,保留最新的 N 个
|
||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||
let retain = crate::settings::effective_backup_retain_count();
|
||
let entries = match fs::read_dir(dir) {
|
||
Ok(iter) => iter
|
||
.filter_map(|entry| entry.ok())
|
||
.filter(|entry| {
|
||
entry
|
||
.path()
|
||
.extension()
|
||
.map(|ext| ext == "db")
|
||
.unwrap_or(false)
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
Err(_) => return Ok(()),
|
||
};
|
||
|
||
if entries.len() <= retain {
|
||
return Ok(());
|
||
}
|
||
|
||
let remove_count = entries.len().saturating_sub(retain);
|
||
let mut sorted = entries;
|
||
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
||
|
||
for entry in sorted.into_iter().take(remove_count) {
|
||
if let Err(err) = fs::remove_file(entry.path()) {
|
||
log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 基础状态校验
|
||
fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {
|
||
let provider_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
let mcp_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
if provider_count == 0 && mcp_count == 0 {
|
||
return Err(AppError::Config(
|
||
"导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 导出数据库为 SQL 文本
|
||
fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result<String, AppError> {
|
||
let mut output = String::new();
|
||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||
let user_version: i64 = conn
|
||
.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
||
.unwrap_or(0);
|
||
|
||
output.push_str(&format!(
|
||
"-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
|
||
));
|
||
output.push_str("PRAGMA foreign_keys=OFF;\n");
|
||
output.push_str(&format!("PRAGMA user_version={user_version};\n"));
|
||
output.push_str("BEGIN TRANSACTION;\n");
|
||
|
||
// 导出 schema
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT type, name, tbl_name, sql
|
||
FROM sqlite_master
|
||
WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
|
||
ORDER BY type='table' DESC, name",
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut tables = Vec::new();
|
||
let mut rows = stmt
|
||
.query([])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
|
||
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
|
||
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 跳过 SQLite 内部对象(如 sqlite_sequence)
|
||
if name.starts_with("sqlite_") {
|
||
continue;
|
||
}
|
||
|
||
output.push_str(&sql);
|
||
output.push_str(";\n");
|
||
|
||
if obj_type == "table" && !name.starts_with("sqlite_") {
|
||
tables.push(name);
|
||
}
|
||
}
|
||
|
||
// 导出数据
|
||
for table in tables {
|
||
if skip_tables.iter().any(|t| *t == table) {
|
||
continue;
|
||
}
|
||
let columns = Self::get_table_columns(conn, &table)?;
|
||
if columns.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let mut stmt = conn
|
||
.prepare(&format!("SELECT * FROM \"{table}\""))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
let mut rows = stmt
|
||
.query([])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
let mut values = Vec::with_capacity(columns.len());
|
||
for idx in 0..columns.len() {
|
||
let value = row
|
||
.get_ref(idx)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
values.push(Self::format_sql_value(value)?);
|
||
}
|
||
|
||
let cols = columns
|
||
.iter()
|
||
.map(|c| format!("\"{c}\""))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
output.push_str(&format!(
|
||
"INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
|
||
values.join(", ")
|
||
));
|
||
}
|
||
}
|
||
|
||
output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
|
||
Ok(output)
|
||
}
|
||
|
||
/// 获取表的列名列表
|
||
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
|
||
let mut stmt = conn
|
||
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
let iter = stmt
|
||
.query_map([], |row| row.get::<_, String>(1))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut columns = Vec::new();
|
||
for col in iter {
|
||
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
|
||
}
|
||
Ok(columns)
|
||
}
|
||
|
||
/// 格式化 SQL 值
|
||
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
|
||
match value {
|
||
ValueRef::Null => Ok("NULL".to_string()),
|
||
ValueRef::Integer(i) => Ok(i.to_string()),
|
||
ValueRef::Real(f) => Ok(f.to_string()),
|
||
ValueRef::Text(t) => {
|
||
let text = std::str::from_utf8(t)
|
||
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
|
||
let escaped = text.replace('\'', "''");
|
||
Ok(format!("'{escaped}'"))
|
||
}
|
||
ValueRef::Blob(bytes) => {
|
||
let mut s = String::from("X'");
|
||
for b in bytes {
|
||
use std::fmt::Write;
|
||
let _ = write!(&mut s, "{b:02X}");
|
||
}
|
||
s.push('\'');
|
||
Ok(s)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// List all database backup files, sorted by creation time (newest first)
|
||
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
|
||
let backup_dir = get_app_config_dir().join("backups");
|
||
if !backup_dir.exists() {
|
||
return Ok(vec![]);
|
||
}
|
||
|
||
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
|
||
.map_err(|e| AppError::io(&backup_dir, e))?
|
||
.filter_map(|e| e.ok())
|
||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||
.filter_map(|e| {
|
||
let metadata = e.metadata().ok()?;
|
||
let filename = e.file_name().to_string_lossy().to_string();
|
||
let size_bytes = metadata.len();
|
||
let created_at = metadata
|
||
.modified()
|
||
.ok()
|
||
.map(|t| {
|
||
let dt: chrono::DateTime<Utc> = t.into();
|
||
dt.to_rfc3339()
|
||
})
|
||
.unwrap_or_default();
|
||
Some(BackupEntry {
|
||
filename,
|
||
size_bytes,
|
||
created_at,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
// Sort by created_at descending (newest first)
|
||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||
Ok(entries)
|
||
}
|
||
|
||
/// Restore database from a backup file. Returns the safety backup ID.
|
||
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
|
||
// Security: validate filename to prevent path traversal
|
||
if filename.contains("..")
|
||
|| filename.contains('/')
|
||
|| filename.contains('\\')
|
||
|| !filename.ends_with(".db")
|
||
{
|
||
return Err(AppError::InvalidInput(
|
||
"Invalid backup filename".to_string(),
|
||
));
|
||
}
|
||
|
||
let backup_dir = get_app_config_dir().join("backups");
|
||
let backup_path = backup_dir.join(filename);
|
||
|
||
if !backup_path.exists() {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"Backup file not found: {filename}"
|
||
)));
|
||
}
|
||
|
||
// Step 1: Create safety backup of current database
|
||
let safety_backup = self.backup_database_file()?;
|
||
let safety_id = safety_backup
|
||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||
.unwrap_or_default();
|
||
|
||
// Step 2: Open the backup file and restore it to the main database
|
||
let source_conn =
|
||
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
{
|
||
let mut main_conn = lock_conn!(self.conn);
|
||
let backup = Backup::new(&source_conn, &mut main_conn)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
backup
|
||
.step(-1)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
// Step 3: Run schema migrations (backup may be from an older version)
|
||
self.create_tables()?;
|
||
self.apply_schema_migrations()?;
|
||
self.ensure_model_pricing_seeded()?;
|
||
|
||
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
|
||
Ok(safety_id)
|
||
}
|
||
|
||
/// Rename a backup file. Returns the new filename.
|
||
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
|
||
// Validate old filename (path traversal + .db suffix)
|
||
if old_filename.contains("..")
|
||
|| old_filename.contains('/')
|
||
|| old_filename.contains('\\')
|
||
|| !old_filename.ends_with(".db")
|
||
{
|
||
return Err(AppError::InvalidInput(
|
||
"Invalid backup filename".to_string(),
|
||
));
|
||
}
|
||
|
||
// Clean new name
|
||
let trimmed = new_name.trim();
|
||
if trimmed.is_empty() {
|
||
return Err(AppError::InvalidInput(
|
||
"New name cannot be empty".to_string(),
|
||
));
|
||
}
|
||
|
||
// Length limit (without .db suffix)
|
||
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
|
||
if name_part.len() > 100 {
|
||
return Err(AppError::InvalidInput(
|
||
"Name too long (max 100 characters)".to_string(),
|
||
));
|
||
}
|
||
|
||
// Prevent path traversal in new name
|
||
if name_part.contains("..")
|
||
|| name_part.contains('/')
|
||
|| name_part.contains('\\')
|
||
|| name_part.contains('\0')
|
||
{
|
||
return Err(AppError::InvalidInput(
|
||
"Invalid characters in new name".to_string(),
|
||
));
|
||
}
|
||
|
||
let new_filename = format!("{name_part}.db");
|
||
|
||
let backup_dir = get_app_config_dir().join("backups");
|
||
let old_path = backup_dir.join(old_filename);
|
||
let new_path = backup_dir.join(&new_filename);
|
||
|
||
if !old_path.exists() {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"Backup file not found: {old_filename}"
|
||
)));
|
||
}
|
||
|
||
if new_path.exists() {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"A backup named '{new_filename}' already exists"
|
||
)));
|
||
}
|
||
|
||
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
|
||
log::info!("Renamed backup: {old_filename} -> {new_filename}");
|
||
Ok(new_filename)
|
||
}
|
||
|
||
/// Delete a backup file permanently.
|
||
pub fn delete_backup(filename: &str) -> Result<(), AppError> {
|
||
// Validate filename (path traversal + .db suffix)
|
||
if filename.contains("..")
|
||
|| filename.contains('/')
|
||
|| filename.contains('\\')
|
||
|| !filename.ends_with(".db")
|
||
{
|
||
return Err(AppError::InvalidInput(
|
||
"Invalid backup filename".to_string(),
|
||
));
|
||
}
|
||
|
||
let backup_path = get_app_config_dir().join("backups").join(filename);
|
||
if !backup_path.exists() {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"Backup file not found: {filename}"
|
||
)));
|
||
}
|
||
|
||
fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;
|
||
log::info!("Deleted backup: {filename}");
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::Database;
|
||
use crate::error::AppError;
|
||
use crate::settings::{update_settings, AppSettings};
|
||
use serial_test::serial;
|
||
|
||
#[test]
|
||
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
|
||
let remote_db = Database::memory()?;
|
||
{
|
||
let conn = crate::database::lock_conn!(remote_db.conn);
|
||
conn.execute(
|
||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||
VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')",
|
||
[],
|
||
)?;
|
||
}
|
||
let remote_sql = remote_db.export_sql_string_for_sync()?;
|
||
|
||
let local_db = Database::memory()?;
|
||
{
|
||
let conn = crate::database::lock_conn!(local_db.conn);
|
||
conn.execute(
|
||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||
VALUES ('local-provider', 'claude', 'Local Provider', '{}', '{}')",
|
||
[],
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO proxy_request_logs (
|
||
request_id, provider_id, app_type, model,
|
||
input_tokens, output_tokens, total_cost_usd,
|
||
latency_ms, status_code, created_at
|
||
) VALUES ('req-1', 'local-provider', 'claude', 'claude-3', 100, 50, '0.01', 120, 200, 1000)",
|
||
[],
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO usage_daily_rollups (
|
||
date, app_type, provider_id, model, request_count, success_count,
|
||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||
total_cost_usd, avg_latency_ms
|
||
) VALUES ('2026-03-01', 'claude', 'local-provider', 'claude-3', 7, 7, 700, 350, 0, 0, '0.07', 120)",
|
||
[],
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO stream_check_logs (
|
||
provider_id, provider_name, app_type, status, success, message,
|
||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||
) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)",
|
||
[],
|
||
)?;
|
||
}
|
||
|
||
local_db.import_sql_string_for_sync(&remote_sql)?;
|
||
|
||
let remote_provider_exists: i64 = {
|
||
let conn = crate::database::lock_conn!(local_db.conn);
|
||
conn.query_row(
|
||
"SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'claude'",
|
||
[],
|
||
|row| row.get(0),
|
||
)?
|
||
};
|
||
assert_eq!(
|
||
remote_provider_exists, 1,
|
||
"remote config should be imported"
|
||
);
|
||
|
||
let (request_logs, rollups, stream_logs): (i64, i64, i64) = {
|
||
let conn = crate::database::lock_conn!(local_db.conn);
|
||
let request_logs =
|
||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||
row.get(0)
|
||
})?;
|
||
let rollups =
|
||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||
row.get(0)
|
||
})?;
|
||
let stream_logs =
|
||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||
row.get(0)
|
||
})?;
|
||
(request_logs, rollups, stream_logs)
|
||
};
|
||
assert_eq!(request_logs, 1, "local request logs should be preserved");
|
||
assert_eq!(rollups, 1, "local rollups should be preserved");
|
||
assert_eq!(
|
||
stream_logs, 1,
|
||
"local stream check logs should be preserved"
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> {
|
||
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||
let test_home =
|
||
std::env::temp_dir().join("cc-switch-periodic-maintenance-backup-disabled-test");
|
||
let _ = std::fs::remove_dir_all(&test_home);
|
||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||
|
||
let mut settings = AppSettings::default();
|
||
settings.backup_interval_hours = Some(0);
|
||
update_settings(settings).expect("disable auto backup");
|
||
|
||
let db = Database::memory()?;
|
||
let now = chrono::Utc::now().timestamp();
|
||
let old_ts = now - 40 * 86400;
|
||
let old_stream_ts = now - 8 * 86400;
|
||
|
||
{
|
||
let conn = crate::database::lock_conn!(db.conn);
|
||
conn.execute(
|
||
"INSERT INTO proxy_request_logs (
|
||
request_id, provider_id, app_type, model,
|
||
input_tokens, output_tokens, total_cost_usd,
|
||
latency_ms, status_code, created_at
|
||
) VALUES ('old-req', 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?1)",
|
||
[old_ts],
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO stream_check_logs (
|
||
provider_id, provider_name, app_type, status, success, message,
|
||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||
) VALUES ('p1', 'Provider 1', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, ?1)",
|
||
[old_stream_ts],
|
||
)?;
|
||
}
|
||
|
||
db.periodic_backup_if_needed()?;
|
||
|
||
let (remaining_request_logs, stream_logs, rollups): (i64, i64, i64) = {
|
||
let conn = crate::database::lock_conn!(db.conn);
|
||
let remaining_request_logs =
|
||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||
row.get(0)
|
||
})?;
|
||
let stream_logs =
|
||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||
row.get(0)
|
||
})?;
|
||
let rollups =
|
||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||
row.get(0)
|
||
})?;
|
||
(remaining_request_logs, stream_logs, rollups)
|
||
};
|
||
|
||
assert_eq!(
|
||
remaining_request_logs, 0,
|
||
"old request logs should still be pruned when auto backup is disabled"
|
||
);
|
||
assert_eq!(
|
||
stream_logs, 0,
|
||
"old stream check logs should still be pruned when auto backup is disabled"
|
||
);
|
||
assert_eq!(rollups, 1, "old request logs should be rolled up");
|
||
|
||
match old_test_home {
|
||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|