Files
CC-Switch/src-tauri/src/database/backup.rs
T
Jason c98913df41 fix(database): reject cross-file statements during SQL import
`import_sql_string_inner` validated only that the file starts with the
`-- CC Switch SQLite 导出` comment, then handed the whole text to
`execute_batch`. Anything after that prefix ran unchecked, so a crafted
backup could `ATTACH DATABASE '/path/x.db'` and create a SQLite file
anywhere the user can write. The side effect lands before
`validate_basic_state`, so the file survives even when the import as a
whole fails. `settings` is in neither SYNC_SKIP_TABLES nor
SYNC_PRESERVE_TABLES, so the WebDAV/S3 sync path reaches the same code.

Install a SQLite authorizer for the duration of the external batch only,
then clear it so our own schema maintenance is unaffected.

Deny what can leave the temp database rather than allow-listing what
`dump_sql` emits. The batch runs on a throwaway NamedTempFile whose
entire contents are already decided by that same SQL, so DELETE/DROP/
UPDATE hand an attacker nothing new -- the only meaningful boundary is
the temp file itself. A strict allow-list only adds the risk of refusing
a legitimate backup whose schema has a shape we did not anticipate.

The denied set was measured, not guessed: `ATTACH DATABASE 'x'`,
`VACUUM INTO 'x'` and bare `VACUUM` all surface as `AuthAction::Attach`,
so one rule covers all three -- which keyword scanning would not, since
`VACUUM INTO` contains no "ATTACH". Also deny vtable creation
(file-backed modules such as csvfile can read and write arbitrary paths)
and `Unknown`, so future SQLite statements fail closed.

Tests cover both denied statements (asserting no file is left on disk,
not merely that the call errors) and a real export round-trip, which
guards against the allow-list regressing into false refusals.
2026-07-29 10:22:29 +08:00

983 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 数据库备份和恢复
//!
//! 提供 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 导出";
/// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory`
/// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。
const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"];
/// 执行外部 SQL 期间的 authorizer:拒绝一切能**离开临时数据库文件**的动作。
///
/// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在
/// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在
/// `validate_basic_state` 之前,导入即使最终失败,文件也已经被创建;而 `settings`
/// 表不在 `SYNC_SKIP_TABLES` / `SYNC_PRESERVE_TABLES` 之列,WebDAV/S3 同步会走
/// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。
///
/// 为什么是 authorizer 而不是「扫描 ATTACH 关键字」:字符串扫描会被 `/*x*/ATTACH`、
/// 大小写、换行绕过,还漏掉 `VACUUM INTO`。authorizer 在 prepare 阶段按**解析结果**
/// 回调,绕不过语法层。
///
/// 为什么是「拒绝越界动作」而不是「只放行 dump_sql 的语句」:这段 SQL 跑在
/// `NamedTempFile` 建的一次性库上,而那个库的全部内容本来就由这份 SQL 决定。
/// 因此 `DELETE` / `DROP` / `UPDATE` 给不了攻击者任何新东西——**唯一有意义的边界
/// 是那个临时文件本身**。按 dump_sql 的产物做严格白名单只会带来误伤风险(用户
/// 库里出现一种没预料到的对象就恢复不了备份),却不多挡任何攻击。
///
/// 越界动作是实测出来的,不是推断的:
/// - `ATTACH DATABASE 'x'`、`VACUUM INTO 'x'`、裸 `VACUUM` **三者都**报
/// `AuthAction::Attach`,所以拒 `Attach` 一条即可覆盖
/// - 文件后端的虚拟表模块(`csvfile`、`zipfile` 等)能读写任意路径 → 拒 vtable
/// - `Unknown` 是 rusqlite 对未识别动作码的兜底 → 未知即拒,将来 SQLite 新增的
/// 跨文件语句会默认落进这里,不依赖有人记得回来补名单
fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization {
use rusqlite::hooks::{AuthAction, Authorization};
let escapes_temp_db = match context.action {
AuthAction::Attach { .. } | AuthAction::Detach { .. } => true,
AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true,
AuthAction::Unknown { .. } => true,
AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS
.iter()
.any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)),
_ => false,
};
if escapes_temp_db {
// SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。
log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action);
Authorization::Deny
} else {
Authorization::Allow
}
}
/// 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()))?;
// authorizer 只覆盖外部 SQL,执行完立刻摘掉:紧随其后的
// `create_tables_on_conn` / `apply_schema_migrations_on_conn` 是本程序自己的
// schema 维护语句,不属于需要设防的输入,没必要让它们也过一遍守卫。
temp_conn.authorizer(Some(import_authorizer));
let batch_result = temp_conn.execute_batch(sql_content);
temp_conn.authorizer(
None::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
);
batch_result.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 import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> {
// `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样,
// 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。
let cases: [(&str, &str); 2] = [
("attach", "ATTACH DATABASE '{path}' AS evil;"),
("vacuum-into", "VACUUM INTO '{path}';"),
];
for (label, template) in cases {
let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite"));
let _ = std::fs::remove_file(&target);
// 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它,
// 真正拦下来的必须是 authorizer。
let malicious = format!(
"{}\n{}\n",
super::CC_SWITCH_SQL_EXPORT_HEADER,
template.replace("{path}", &target.display().to_string())
);
let db = Database::memory()?;
let result = db.import_sql_string(&malicious);
assert!(result.is_err(), "{label} 必须被拒绝");
// 光报错不够:文件创建发生在 prepare 之后、`validate_basic_state` 之前,
// 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。
assert!(
!target.exists(),
"被拒绝的 {label} 不得在磁盘上留下文件: {}",
target.display()
);
let _ = std::fs::remove_file(&target);
}
Ok(())
}
#[test]
fn import_still_accepts_a_genuine_export() -> Result<(), AppError> {
// 白名单收得紧,必须有一条回归防线证明它没误伤自家导出格式——
// 这条测试红了就说明 dump_sql 写出了白名单没覆盖的语句。
let source = Database::memory()?;
{
let conn = crate::database::lock_conn!(source.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES ('p1', 'claude', 'Provider One', '{}', '{}')",
[],
)?;
}
let exported = source.export_sql_string()?;
let target = Database::memory()?;
target.import_sql_string(&exported)?;
let conn = crate::database::lock_conn!(target.conn);
let name: String = conn.query_row(
"SELECT name FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|row| row.get(0),
)?;
assert_eq!(name, "Provider One");
Ok(())
}
#[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 settings = AppSettings {
backup_interval_hours: Some(0),
..AppSettings::default()
};
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(())
}
}