//! 数据库备份和恢复 //! //! 提供 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 { 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 { 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 { 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 { 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 { self.import_sql_string_inner(sql_raw, SYNC_PRESERVE_TABLES) } fn import_sql_string_inner( &self, sql_raw: &str, preserve_tables: &[&str], ) -> Result { 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::) -> 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 { 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::>() .join(", "); let cols = columns .iter() .map(|column| format!("\"{column}\"")) .collect::>() .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, 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::>(), 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 { 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::>() .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, 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 { 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, AppError> { let backup_dir = get_app_config_dir().join("backups"); if !backup_dir.exists() { return Ok(vec![]); } let mut entries: Vec = 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 = 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 { // 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 { // 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(()) } }