//! 数据库备份和恢复 //! //! 提供 SQL 导出/导入和二进制快照备份功能。 use super::schema::{CanonicalStage, MigrationRunContext}; use super::{lock_conn, Database, SCHEMA_VERSION}; use crate::config::get_app_config_dir; use crate::error::AppError; use chrono::{Local, Utc}; use rusqlite::backup::{Backup, StepResult}; use rusqlite::config::DbConfig; use rusqlite::limits::Limit; use rusqlite::types::{Value, ValueRef}; use rusqlite::{Connection, OpenFlags, OptionalExtension}; use std::fs::{self, File, Metadata, OpenOptions}; use std::io::{Read, Take, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; pub(crate) const MAX_SQL_IMPORT_BYTES: u64 = 256 * 1024 * 1024; pub(crate) const MAX_BINARY_RESTORE_BYTES: u64 = 2 * 1024 * 1024 * 1024; pub(crate) const MAX_SCRATCH_BYTES: u64 = 2 * 1024 * 1024 * 1024; const MAX_SQL_VALUE_BYTES: i32 = 64 * 1024 * 1024; const MAX_VM_STEPS: u64 = 50_000_000; const PROGRESS_GRANULARITY: u64 = 1_000; const MAX_PAGE_COUNT: u64 = 524_288; const BACKUP_PAGES_PER_STEP: i32 = 256; const MAX_BACKUP_TRANSIENT_RETRIES: u32 = 100; const MAX_BACKUP_STEPS: u32 = 100_000; const BACKUP_RETRY_DELAY: Duration = Duration::from_millis(10); #[cfg(test)] thread_local! { static TEST_MAX_VM_STEPS: std::cell::Cell> = const { std::cell::Cell::new(None) }; static TEST_MAX_PAGE_COUNT: std::cell::Cell> = const { std::cell::Cell::new(None) }; static TEST_MAX_BACKUP_TRANSIENT_RETRIES: std::cell::Cell> = const { std::cell::Cell::new(None) }; static TEST_AFTER_SAFETY_BACKUP: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } fn max_vm_steps() -> u64 { #[cfg(test)] if let Some(limit) = TEST_MAX_VM_STEPS.with(std::cell::Cell::get) { return limit; } MAX_VM_STEPS } fn max_page_count() -> u64 { #[cfg(test)] if let Some(limit) = TEST_MAX_PAGE_COUNT.with(std::cell::Cell::get) { return limit; } MAX_PAGE_COUNT } fn max_backup_transient_retries() -> u32 { #[cfg(test)] if let Some(limit) = TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(std::cell::Cell::get) { return limit; } MAX_BACKUP_TRANSIENT_RETRIES } #[cfg(test)] fn run_after_safety_backup_test_seam() { if let Some(hook) = TEST_AFTER_SAFETY_BACKUP.with(|slot| slot.borrow_mut().take()) { hook(); } } /// `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'` 的副作用发生在 /// canonical data validation 之前,导入即使最终失败,文件也已经被创建;而 `settings` /// 表不在同步 skip/commit-boundary overlay 之列,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_scratch_boundary = context .database_name .is_some_and(|name| name.eq_ignore_ascii_case("temp")) || match context.action { AuthAction::Attach { .. } | AuthAction::Detach { .. } => true, AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true, AuthAction::CreateTempIndex { .. } | AuthAction::CreateTempTable { .. } | AuthAction::CreateTempTrigger { .. } | AuthAction::CreateTempView { .. } | AuthAction::DropTempIndex { .. } | AuthAction::DropTempTable { .. } | AuthAction::DropTempTrigger { .. } | AuthAction::DropTempView { .. } => true, // Genuine exports can contain expression indexes (for example, // COALESCE in the request-log dedupe index), so ordinary built-ins // must remain usable while the untrusted schema is assembled. // No application functions are registered on this connection and // extension loading is never enabled; deny the SQL entry point too. AuthAction::Function { function_name } => { function_name.eq_ignore_ascii_case("load_extension") } AuthAction::Unknown { .. } => true, AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS .iter() .any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)), _ => false, }; if escapes_scratch_boundary { // 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", "session_log_sync", "pi_provider_projections", "skill_deployments", ]; /// Exact file/deployment ownership belongs to the current device. Portable SQL /// exports carry table shape but never rows, and imports restore live rows. const DEVICE_LOCAL_TABLES: &[&str] = &[ "session_log_sync", "pi_provider_projections", "skill_deployments", ]; /// Gateway credentials are installation-local even though legacy releases /// stored them in the otherwise-portable key/value table. Neither SQL export /// flavor nor an untrusted restore source may transfer these rows. The live /// value is copied at the publication boundary until its owning subsystem has /// durably migrated it out of SQLite. const DEVICE_LOCAL_SETTING_KEYS: &[&str] = &["claude_desktop_gateway_token", "pi_gateway_token"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RestorePolicy { PortableIncoming, PreserveLive, RebuildRuntime, SeedCanonical, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StorageKind { Text, Integer, Real, } /// Semantic range of an INTEGER column at the production hydration boundary. /// /// SQLite stores every INTEGER as an `i64`, while several public projections /// narrow those values to `bool`, `u8`, `u16`, `u32`, `u64`, or `usize`. /// Restore must reject values that those projections would wrap or reinterpret /// instead of publishing a database that fails only when the row is later read. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IntegerDomain { Unrestricted, Boolean, NonNegative, SortIndex, Unsigned8, Unsigned16, NonNegativeI32, Unsigned32, InputTokenSemantics, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RealDomain { NotReal, FiniteUnitInterval, } #[derive(Debug, Clone, Copy)] struct RestoreColumnSpec { name: &'static str, storage: StorageKind, nullable: bool, integer_domain: IntegerDomain, real_domain: RealDomain, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RestoreRowValidator { /// Storage/nullability is the complete portable contract for this row; /// no JSON or decimal domain decoding is intentionally required. OpaqueStorage, Provider, Mcp, Profile, ProxyConfig, NonNegativeDecimalColumns(&'static [usize]), } #[derive(Debug, Clone, Copy)] struct RestoreTableSpec { name: &'static str, policy: RestorePolicy, columns: &'static [RestoreColumnSpec], validator: RestoreRowValidator, parents: &'static [&'static str], } macro_rules! text_col { ($name:literal) => { RestoreColumnSpec { name: $name, storage: StorageKind::Text, nullable: false, integer_domain: IntegerDomain::Unrestricted, real_domain: RealDomain::NotReal, } }; } macro_rules! nullable_text_col { ($name:literal) => { RestoreColumnSpec { name: $name, storage: StorageKind::Text, nullable: true, integer_domain: IntegerDomain::Unrestricted, real_domain: RealDomain::NotReal, } }; } macro_rules! integer_col { ($name:literal, $domain:ident) => { RestoreColumnSpec { name: $name, storage: StorageKind::Integer, nullable: false, integer_domain: IntegerDomain::$domain, real_domain: RealDomain::NotReal, } }; } macro_rules! nullable_integer_col { ($name:literal, $domain:ident) => { RestoreColumnSpec { name: $name, storage: StorageKind::Integer, nullable: true, integer_domain: IntegerDomain::$domain, real_domain: RealDomain::NotReal, } }; } macro_rules! real_col { ($name:literal, $domain:ident) => { RestoreColumnSpec { name: $name, storage: StorageKind::Real, nullable: false, integer_domain: IntegerDomain::Unrestricted, real_domain: RealDomain::$domain, } }; } const PROVIDERS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("id"), text_col!("app_type"), text_col!("name"), text_col!("settings_config"), nullable_text_col!("website_url"), nullable_text_col!("category"), nullable_integer_col!("created_at", Unrestricted), nullable_integer_col!("sort_index", SortIndex), nullable_text_col!("notes"), nullable_text_col!("icon"), nullable_text_col!("icon_color"), text_col!("meta"), integer_col!("is_current", Boolean), integer_col!("in_failover_queue", Boolean), text_col!("cost_multiplier"), nullable_text_col!("limit_daily_usd"), nullable_text_col!("limit_monthly_usd"), nullable_text_col!("provider_type"), ]; const PROVIDER_ENDPOINTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ // Explicit INTEGER PRIMARY KEY values are portable data. SQLite permits // negative explicit IDs even though AUTOINCREMENT only generates positive // values, so restore must preserve the full i64 domain. integer_col!("id", Unrestricted), text_col!("provider_id"), text_col!("app_type"), text_col!("url"), nullable_integer_col!("added_at", Unrestricted), nullable_integer_col!("last_used", Unrestricted), ]; const MCP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("id"), text_col!("name"), text_col!("server_config"), nullable_text_col!("description"), nullable_text_col!("homepage"), nullable_text_col!("docs"), text_col!("tags"), integer_col!("enabled_claude", Boolean), integer_col!("enabled_codex", Boolean), integer_col!("enabled_gemini", Boolean), integer_col!("enabled_grokbuild", Boolean), integer_col!("enabled_opencode", Boolean), integer_col!("enabled_hermes", Boolean), ]; const PROMPTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("id"), text_col!("app_type"), text_col!("name"), text_col!("content"), nullable_text_col!("description"), integer_col!("enabled", Boolean), nullable_integer_col!("created_at", Unrestricted), nullable_integer_col!("updated_at", Unrestricted), ]; const SKILLS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("id"), text_col!("name"), nullable_text_col!("description"), text_col!("directory"), nullable_text_col!("repo_owner"), nullable_text_col!("repo_name"), nullable_text_col!("repo_branch"), nullable_text_col!("readme_url"), integer_col!("enabled_claude", Boolean), integer_col!("enabled_codex", Boolean), integer_col!("enabled_gemini", Boolean), integer_col!("enabled_grokbuild", Boolean), integer_col!("enabled_opencode", Boolean), integer_col!("enabled_hermes", Boolean), integer_col!("enabled_pi", Boolean), integer_col!("installed_at", Unrestricted), nullable_text_col!("content_hash"), integer_col!("updated_at", Unrestricted), ]; const SKILL_REPOS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("owner"), text_col!("name"), text_col!("branch"), integer_col!("enabled", Boolean), ]; const SETTINGS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[text_col!("key"), nullable_text_col!("value")]; const PROXY_CONFIG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("app_type"), integer_col!("proxy_enabled", Boolean), text_col!("listen_address"), integer_col!("listen_port", Unsigned16), integer_col!("enable_logging", Boolean), integer_col!("enabled", Boolean), integer_col!("auto_failover_enabled", Boolean), integer_col!("max_retries", Unsigned8), integer_col!("streaming_first_byte_timeout", NonNegativeI32), integer_col!("streaming_idle_timeout", NonNegativeI32), integer_col!("non_streaming_timeout", NonNegativeI32), integer_col!("circuit_failure_threshold", NonNegativeI32), integer_col!("circuit_success_threshold", NonNegativeI32), integer_col!("circuit_timeout_seconds", NonNegativeI32), real_col!("circuit_error_rate_threshold", FiniteUnitInterval), integer_col!("circuit_min_requests", NonNegativeI32), text_col!("default_cost_multiplier"), text_col!("pricing_model_source"), integer_col!("live_takeover_active", Boolean), text_col!("created_at"), text_col!("updated_at"), ]; const PROVIDER_HEALTH_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("provider_id"), text_col!("app_type"), integer_col!("is_healthy", Boolean), integer_col!("consecutive_failures", Unsigned32), nullable_text_col!("last_success_at"), nullable_text_col!("last_failure_at"), nullable_text_col!("last_error"), text_col!("updated_at"), ]; const PROXY_LOG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("request_id"), text_col!("provider_id"), text_col!("app_type"), text_col!("model"), nullable_text_col!("request_model"), nullable_text_col!("pricing_model"), integer_col!("input_tokens", Unsigned32), integer_col!("output_tokens", Unsigned32), integer_col!("cache_read_tokens", Unsigned32), integer_col!("cache_creation_tokens", Unsigned32), integer_col!("input_token_semantics", InputTokenSemantics), text_col!("input_cost_usd"), text_col!("output_cost_usd"), text_col!("cache_read_cost_usd"), text_col!("cache_creation_cost_usd"), text_col!("total_cost_usd"), integer_col!("latency_ms", NonNegative), nullable_integer_col!("first_token_ms", NonNegative), nullable_integer_col!("duration_ms", NonNegative), integer_col!("status_code", Unsigned16), nullable_text_col!("error_message"), nullable_text_col!("session_id"), nullable_text_col!("provider_type"), integer_col!("is_streaming", Boolean), text_col!("cost_multiplier"), integer_col!("created_at", Unrestricted), text_col!("data_source"), ]; const MODEL_PRICING_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("model_id"), text_col!("display_name"), text_col!("input_cost_per_million"), text_col!("output_cost_per_million"), text_col!("cache_read_cost_per_million"), text_col!("cache_creation_cost_per_million"), ]; const STREAM_LOG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ integer_col!("id", Unrestricted), text_col!("provider_id"), text_col!("provider_name"), text_col!("app_type"), text_col!("status"), integer_col!("success", Boolean), text_col!("message"), nullable_integer_col!("response_time_ms", NonNegative), nullable_integer_col!("http_status", Unsigned16), nullable_text_col!("model_used"), nullable_integer_col!("retry_count", Unsigned32), integer_col!("tested_at", Unrestricted), ]; const PROXY_LIVE_BACKUP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("app_type"), text_col!("original_config"), text_col!("backed_up_at"), ]; const USAGE_ROLLUP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("date"), text_col!("app_type"), text_col!("provider_id"), text_col!("model"), text_col!("request_model"), text_col!("pricing_model"), integer_col!("request_count", NonNegative), integer_col!("success_count", NonNegative), integer_col!("input_tokens", NonNegative), integer_col!("output_tokens", NonNegative), integer_col!("cache_read_tokens", NonNegative), integer_col!("cache_creation_tokens", NonNegative), integer_col!("input_token_semantics", InputTokenSemantics), text_col!("total_cost_usd"), integer_col!("avg_latency_ms", NonNegative), ]; const SESSION_SYNC_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("file_path"), integer_col!("last_modified", Unrestricted), integer_col!("last_line_offset", NonNegative), integer_col!("last_synced_at", Unrestricted), ]; const PROFILE_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("id"), text_col!("name"), text_col!("payload"), nullable_integer_col!("sort_order", Unrestricted), nullable_integer_col!("created_at", Unrestricted), nullable_integer_col!("updated_at", Unrestricted), ]; const PI_PROJECTION_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("provider_id"), text_col!("provider_key"), integer_col!("created_at", Unrestricted), integer_col!("updated_at", Unrestricted), ]; const SKILL_DEPLOYMENT_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("app_type"), text_col!("skill_id"), text_col!("destination"), text_col!("destination_key"), text_col!("method"), text_col!("source_identity"), nullable_text_col!("deployed_digest"), integer_col!("created_at", Unrestricted), integer_col!("updated_at", Unrestricted), ]; /// Parent-before-child order is also the canonical copy order. const RESTORE_TABLE_SPECS: &[RestoreTableSpec] = &[ RestoreTableSpec { name: "providers", policy: RestorePolicy::PortableIncoming, columns: PROVIDERS_RESTORE_COLUMNS, validator: RestoreRowValidator::Provider, parents: &[], }, RestoreTableSpec { name: "provider_endpoints", policy: RestorePolicy::PortableIncoming, columns: PROVIDER_ENDPOINTS_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &["providers"], }, RestoreTableSpec { name: "mcp_servers", policy: RestorePolicy::PortableIncoming, columns: MCP_RESTORE_COLUMNS, validator: RestoreRowValidator::Mcp, parents: &[], }, RestoreTableSpec { name: "prompts", policy: RestorePolicy::PortableIncoming, columns: PROMPTS_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "skills", policy: RestorePolicy::PortableIncoming, columns: SKILLS_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "skill_repos", policy: RestorePolicy::PortableIncoming, columns: SKILL_REPOS_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "settings", policy: RestorePolicy::PortableIncoming, columns: SETTINGS_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "proxy_config", policy: RestorePolicy::PortableIncoming, columns: PROXY_CONFIG_RESTORE_COLUMNS, validator: RestoreRowValidator::ProxyConfig, parents: &[], }, RestoreTableSpec { name: "provider_health", policy: RestorePolicy::RebuildRuntime, columns: PROVIDER_HEALTH_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &["providers"], }, RestoreTableSpec { name: "proxy_request_logs", policy: RestorePolicy::PortableIncoming, columns: PROXY_LOG_RESTORE_COLUMNS, validator: RestoreRowValidator::NonNegativeDecimalColumns(&[11, 12, 13, 14, 15, 24]), parents: &[], }, RestoreTableSpec { name: "model_pricing", policy: RestorePolicy::PortableIncoming, columns: MODEL_PRICING_RESTORE_COLUMNS, validator: RestoreRowValidator::NonNegativeDecimalColumns(&[2, 3, 4, 5]), parents: &[], }, RestoreTableSpec { name: "stream_check_logs", policy: RestorePolicy::PortableIncoming, columns: STREAM_LOG_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "proxy_live_backup", policy: RestorePolicy::PortableIncoming, columns: PROXY_LIVE_BACKUP_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "usage_daily_rollups", policy: RestorePolicy::PortableIncoming, columns: USAGE_ROLLUP_RESTORE_COLUMNS, validator: RestoreRowValidator::NonNegativeDecimalColumns(&[13]), parents: &[], }, RestoreTableSpec { name: "session_log_sync", policy: RestorePolicy::PreserveLive, columns: SESSION_SYNC_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "profiles", policy: RestorePolicy::PortableIncoming, columns: PROFILE_RESTORE_COLUMNS, validator: RestoreRowValidator::Profile, parents: &[], }, RestoreTableSpec { name: "pi_provider_projections", policy: RestorePolicy::PreserveLive, columns: PI_PROJECTION_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, RestoreTableSpec { name: "skill_deployments", policy: RestorePolicy::PreserveLive, columns: SKILL_DEPLOYMENT_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], }, ]; const SYNC_LIVE_OVERLAY_TABLES: &[&str] = &[ "proxy_request_logs", "stream_check_logs", "proxy_live_backup", "usage_daily_rollups", ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RestoreFlavor { UserRestore, Sync, } /// An untrusted schema can only exist behind this private wrapper. There is no /// conversion from it to CanonicalStage. struct UntrustedScratch { connection: Connection, _file: NamedTempFile, cancellation: Arc, } #[cfg(unix)] fn open_nofollow(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) .open(path) } #[cfg(windows)] fn open_nofollow(path: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; OpenOptions::new() .read(true) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(path) } #[cfg(all(not(unix), not(windows)))] fn open_nofollow(path: &Path) -> std::io::Result { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, format!( "nofollow restore-source opens are unsupported on this platform: {}", path.display() ), )) } #[cfg(unix)] fn same_file_identity(opened: &Metadata, current: &Metadata) -> bool { use std::os::unix::fs::MetadataExt; opened.dev() == current.dev() && opened.ino() == current.ino() } #[cfg(unix)] fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result { let opened = opened.metadata()?; let current = current.metadata()?; Ok(same_file_identity(&opened, ¤t)) } #[cfg(windows)] fn windows_file_identity(file: &File) -> std::io::Result<(u64, [u8; 16])> { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::Storage::FileSystem::{ FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO, }; let mut information = FILE_ID_INFO::default(); // SAFETY: `file` owns a live handle for this call, and `information` is a // valid writable FILE_ID_INFO buffer of the size passed to Windows. let succeeded = unsafe { GetFileInformationByHandleEx( file.as_raw_handle(), FileIdInfo, std::ptr::addr_of_mut!(information).cast(), std::mem::size_of::() as u32, ) } != 0; if succeeded { Ok(( information.VolumeSerialNumber, information.FileId.Identifier, )) } else { Err(std::io::Error::last_os_error()) } } #[cfg(windows)] fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result { Ok(windows_file_identity(opened)? == windows_file_identity(current)?) } #[cfg(windows)] fn metadata_is_reparse_point(metadata: &Metadata) -> bool { use std::os::windows::fs::MetadataExt; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } #[cfg(not(windows))] fn metadata_is_reparse_point(_metadata: &Metadata) -> bool { false } #[cfg(all(not(unix), not(windows)))] fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result { let _ = (opened, current); Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "stable restore-source identities are unsupported on this platform", )) } #[cfg(unix)] fn open_backup_directory(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(path) } #[cfg(windows)] fn open_backup_directory(path: &Path) -> std::io::Result { use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE, }; OpenOptions::new() .read(true) // Omitting FILE_SHARE_DELETE keeps the opened directory from being // renamed or replaced while its child is resolved and copied. .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) .open(path) } #[cfg(all(not(unix), not(windows)))] fn open_backup_directory(path: &Path) -> std::io::Result { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, format!( "anchored backup-directory opens are unsupported on this platform: {}", path.display() ), )) } #[cfg(unix)] fn open_backup_child( directory: &File, _directory_path: &Path, filename: &std::ffi::OsStr, ) -> std::io::Result { use std::os::fd::{AsRawFd, FromRawFd}; use std::os::unix::ffi::OsStrExt; let filename = std::ffi::CString::new(filename.as_bytes()) .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; let descriptor = unsafe { libc::openat( directory.as_raw_fd(), filename.as_ptr(), libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, ) }; if descriptor < 0 { Err(std::io::Error::last_os_error()) } else { // SAFETY: openat returned a new owned descriptor and this branch // transfers its sole ownership into File. Ok(unsafe { File::from_raw_fd(descriptor) }) } } #[cfg(windows)] fn open_backup_child( directory: &File, _directory_path: &Path, filename: &std::ffi::OsStr, ) -> std::io::Result { use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsRawHandle, FromRawHandle}; use windows_sys::Wdk::Foundation::OBJECT_ATTRIBUTES; use windows_sys::Wdk::Storage::FileSystem::{ NtCreateFile, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, }; use windows_sys::Win32::Foundation::{ CloseHandle, RtlNtStatusToDosError, INVALID_HANDLE_VALUE, OBJ_CASE_INSENSITIVE, UNICODE_STRING, }; use windows_sys::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_NORMAL, FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_SHARE_READ, FILE_SHARE_WRITE, SYNCHRONIZE, }; use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; let mut wide = filename.encode_wide().collect::>(); if wide.contains(&0) { return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput)); } let byte_length = wide .len() .checked_mul(std::mem::size_of::()) .and_then(|length| u16::try_from(length).ok()) .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; let object_name = UNICODE_STRING { Length: byte_length, MaximumLength: byte_length, Buffer: wide.as_mut_ptr(), }; let object_attributes = OBJECT_ATTRIBUTES { Length: std::mem::size_of::() as u32, RootDirectory: directory.as_raw_handle(), ObjectName: std::ptr::addr_of!(object_name), Attributes: OBJ_CASE_INSENSITIVE, SecurityDescriptor: std::ptr::null(), SecurityQualityOfService: std::ptr::null(), }; let mut io_status = IO_STATUS_BLOCK::default(); let mut handle = INVALID_HANDLE_VALUE; // SAFETY: `directory` remains live for the call and is installed as // RootDirectory; `object_name` points to `wide` for the same duration. // NtCreateFile writes only the handle and IO status output buffers. let status = unsafe { NtCreateFile( std::ptr::addr_of_mut!(handle), FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE, std::ptr::addr_of!(object_attributes), std::ptr::addr_of_mut!(io_status), std::ptr::null(), FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN, FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, std::ptr::null(), 0, ) }; if status < 0 { if handle != INVALID_HANDLE_VALUE && !handle.is_null() { // SAFETY: a non-invalid handle written on the failure path is // still owned by this function and must not leak. unsafe { CloseHandle(handle); } } return Err(std::io::Error::from_raw_os_error( unsafe { RtlNtStatusToDosError(status) } as i32, )); } if handle == INVALID_HANDLE_VALUE || handle.is_null() { return Err(std::io::Error::other( "NtCreateFile succeeded without returning a file handle", )); } // SAFETY: NtCreateFile returned a new owned file handle; this transfers its // sole ownership to File. Ok(unsafe { File::from_raw_handle(handle) }) } #[cfg(all(not(unix), not(windows)))] fn open_backup_child( _directory: &File, _directory_path: &Path, _filename: &std::ffi::OsStr, ) -> std::io::Result { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "anchored backup-child opens are unsupported on this platform", )) } fn open_validated_restore_source( path: &Path, max_bytes: u64, changed_message: &str, ) -> Result<(File, Metadata), AppError> { // The path metadata is an early shape/size rejection only. The opened // descriptor is the authority used for every byte read below. On Windows, // std::fs::Metadata has no stable file ID, so it must never be compared by // the old length+mtime surrogate. let initial = validate_regular_file(path, max_bytes)?; let file = open_nofollow(path).map_err(|error| AppError::io(path, error))?; let opened = file.metadata().map_err(|error| AppError::io(path, error))?; #[cfg(unix)] let changed_before_open = !same_file_identity(&initial, &opened); #[cfg(not(unix))] let changed_before_open = { let _shape_only = initial; false }; if !opened.file_type().is_file() || opened.len() > max_bytes || changed_before_open { return Err(AppError::InvalidInput(format!( "{changed_message}: {}", path.display() ))); } Ok((file, opened)) } fn verify_open_file_still_current( path: &Path, opened_file: &File, opened: &Metadata, consumed_len: u64, ) -> Result<(), AppError> { let completed = opened_file .metadata() .map_err(|error| AppError::io(path, error))?; let current_path = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; let current_file = open_nofollow(path).map_err(|error| AppError::io(path, error))?; let current_opened = current_file .metadata() .map_err(|error| AppError::io(path, error))?; let same_identity = same_open_file_identity(opened_file, ¤t_file) .map_err(|error| AppError::io(path, error))?; if !current_path.file_type().is_file() || !current_opened.file_type().is_file() || !same_identity || opened.len() != consumed_len || completed.len() != consumed_len || current_path.len() != consumed_len || current_opened.len() != consumed_len || opened.modified().ok() != completed.modified().ok() { return Err(AppError::InvalidInput(format!( "restore source changed while it was read: {}", path.display() ))); } Ok(()) } fn validate_regular_file(path: &Path, max_bytes: u64) -> Result { let metadata = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; if !metadata.file_type().is_file() || metadata_is_reparse_point(&metadata) { return Err(AppError::InvalidInput(format!( "restore source must be a regular non-symlink file: {}", path.display() ))); } if metadata.len() > max_bytes { return Err(AppError::InvalidInput(format!( "restore source exceeds {max_bytes} bytes: {}", path.display() ))); } Ok(metadata) } fn validate_backup_filename(filename: &str) -> Result<(), AppError> { let path = Path::new(filename); let mut components = path.components(); let exactly_one_normal_component = matches!( (components.next(), components.next()), (Some(std::path::Component::Normal(name)), None) if name == path.as_os_str() ); if filename.is_empty() || filename.contains('\0') || filename.contains(':') || !filename.ends_with(".db") || !exactly_one_normal_component { return Err(AppError::InvalidInput( "Invalid backup filename".to_string(), )); } Ok(()) } fn open_validated_backup_directory(path: &Path) -> Result { let directory = open_backup_directory(path).map_err(|error| AppError::io(path, error))?; let metadata = directory .metadata() .map_err(|error| AppError::io(path, error))?; if !metadata.file_type().is_dir() { return Err(AppError::InvalidInput(format!( "backup directory must be a non-symlink directory: {}", path.display() ))); } #[cfg(windows)] { use std::os::windows::fs::MetadataExt; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(AppError::InvalidInput(format!( "backup directory must not be a reparse point: {}", path.display() ))); } } Ok(directory) } fn validate_backup_directory(path: &Path) -> Result<(), AppError> { open_validated_backup_directory(path).map(|_| ()) } fn read_restore_file(path: &Path, max_bytes: u64) -> Result, AppError> { let (mut file, opened) = open_validated_restore_source(path, max_bytes, "restore source changed before open")?; let mut bytes = Vec::new(); let mut limited: Take<&mut File> = Read::by_ref(&mut file).take(max_bytes + 1); limited .read_to_end(&mut bytes) .map_err(|error| AppError::io(path, error))?; if bytes.len() as u64 > max_bytes { return Err(AppError::InvalidInput(format!( "restore source exceeds {max_bytes} bytes: {}", path.display() ))); } verify_open_file_still_current(path, &file, &opened, bytes.len() as u64)?; Ok(bytes) } /// Snapshot an already-validated restore source through the `O_NOFOLLOW` /// descriptor into a process-owned temporary file. /// /// SQLite's path-based open API would otherwise resolve the user-controlled /// backup path a second time after the identity check. The owned snapshot is /// the only path SQLite ever opens, so replacing any component of the original /// path cannot redirect the bytes consumed by the parser. fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result { // File-level preflight rejects links/reparse points and oversized inputs // before the authoritative directory-handle-relative open below. let initial = validate_regular_file(path, max_bytes)?; let directory_path = path.parent().ok_or_else(|| { AppError::InvalidInput(format!( "binary restore source has no parent directory: {}", path.display() )) })?; let filename = path.file_name().ok_or_else(|| { AppError::InvalidInput(format!( "binary restore source has no filename: {}", path.display() )) })?; let directory = open_validated_backup_directory(directory_path)?; let mut source = open_backup_child(&directory, directory_path, filename) .map_err(|error| AppError::io(path, error))?; let opened = source .metadata() .map_err(|error| AppError::io(path, error))?; #[cfg(unix)] let changed_before_open = !same_file_identity(&initial, &opened); #[cfg(not(unix))] let changed_before_open = { let _shape_only = initial; false }; if !opened.file_type().is_file() || metadata_is_reparse_point(&opened) || opened.len() > max_bytes || changed_before_open { return Err(AppError::InvalidInput(format!( "binary restore source must be a bounded regular file: {}", path.display() ))); } let mut owned = NamedTempFile::new().map_err(|error| AppError::IoContext { context: "create owned binary restore snapshot".to_string(), source: error, })?; let copied = std::io::copy( &mut Read::by_ref(&mut source).take(max_bytes + 1), owned.as_file_mut(), ) .map_err(|error| AppError::io(path, error))?; if copied > max_bytes { return Err(AppError::InvalidInput(format!( "restore source exceeds {max_bytes} bytes: {}", path.display() ))); } owned .as_file_mut() .flush() .map_err(|error| AppError::io(owned.path(), error))?; let completed = source .metadata() .map_err(|error| AppError::io(path, error))?; let current = open_backup_child(&directory, directory_path, filename) .map_err(|error| AppError::io(path, error))?; let current_metadata = current .metadata() .map_err(|error| AppError::io(path, error))?; let current_directory = open_validated_backup_directory(directory_path).map_err(|error| { AppError::InvalidInput(format!( "backup directory changed while source was read: {error}" )) })?; let same_source = same_open_file_identity(&source, ¤t).map_err(|error| AppError::io(path, error))?; let same_directory = same_open_file_identity(&directory, ¤t_directory) .map_err(|error| AppError::io(directory_path, error))?; if !same_source || !same_directory || metadata_is_reparse_point(&completed) || metadata_is_reparse_point(¤t_metadata) || opened.len() != copied || completed.len() != copied || current_metadata.len() != copied || opened.modified().ok() != completed.modified().ok() { return Err(AppError::InvalidInput(format!( "binary restore source changed while it was read: {}", path.display() ))); } Ok(owned) } fn run_backup_to_completion(backup: &Backup<'_, '_>, context: &str) -> Result<(), AppError> { let mut transient_retries = 0_u32; let mut total_steps = 0_u32; loop { if total_steps >= MAX_BACKUP_STEPS { let progress = backup.progress(); return Err(AppError::Database(format!( "{context}: SQLite backup exceeded {MAX_BACKUP_STEPS} bounded steps \ (remaining {}, total {})", progress.remaining, progress.pagecount ))); } total_steps += 1; let result = backup .step(BACKUP_PAGES_PER_STEP) .map_err(|error| AppError::Database(format!("{context}: {error}")))?; match result { StepResult::Done => return Ok(()), StepResult::More => transient_retries = 0, StepResult::Busy | StepResult::Locked => { if transient_retries >= max_backup_transient_retries() { let progress = backup.progress(); return Err(AppError::Database(format!( "{context}: SQLite backup did not complete after {} transient retries \ (remaining {}, total {})", transient_retries, progress.remaining, progress.pagecount ))); } transient_retries += 1; std::thread::sleep(BACKUP_RETRY_DELAY); } _ => { return Err(AppError::Database(format!( "{context}: SQLite returned an unsupported backup step result" ))); } } } } impl UntrustedScratch { fn empty() -> Result { let file = NamedTempFile::new().map_err(|error| AppError::IoContext { context: "create untrusted restore scratch".to_string(), source: error, })?; let connection = Connection::open(file.path()).map_err(|error| AppError::Database(error.to_string()))?; let cancellation = Arc::new(AtomicBool::new(false)); let scratch = Self { connection, _file: file, cancellation, }; scratch.configure_untrusted_execution()?; Ok(scratch) } fn configure_untrusted_execution(&self) -> Result<(), AppError> { self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, false) .map_err(|error| AppError::Database(error.to_string()))?; self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, false) .map_err(|error| AppError::Database(error.to_string()))?; self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) .map_err(|error| AppError::Database(error.to_string()))?; self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_DQS_DDL, false) .map_err(|error| AppError::Database(error.to_string()))?; self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_DQS_DML, false) .map_err(|error| AppError::Database(error.to_string()))?; self.connection.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0); self.connection .set_limit(Limit::SQLITE_LIMIT_LENGTH, MAX_SQL_VALUE_BYTES); self.connection .set_limit(Limit::SQLITE_LIMIT_SQL_LENGTH, MAX_SQL_IMPORT_BYTES as i32); self.connection .set_limit(Limit::SQLITE_LIMIT_VDBE_OP, 1_000_000); self.connection .set_limit(Limit::SQLITE_LIMIT_TRIGGER_DEPTH, 0); self.connection .execute_batch(&format!( "PRAGMA trusted_schema = OFF; PRAGMA foreign_keys = OFF; PRAGMA max_page_count = {};", max_page_count() )) .map_err(|error| AppError::Database(error.to_string()))?; let steps = Arc::new(AtomicU64::new(0)); let cancellation = Arc::clone(&self.cancellation); let max_steps = max_vm_steps(); self.connection.progress_handler( PROGRESS_GRANULARITY as i32, Some(move || { cancellation.load(Ordering::Relaxed) || steps.fetch_add(PROGRESS_GRANULARITY, Ordering::Relaxed) >= max_steps }), ); Ok(()) } fn from_sql(sql: &str) -> Result { if sql.len() as u64 > MAX_SQL_IMPORT_BYTES { return Err(AppError::InvalidInput(format!( "SQL import exceeds {MAX_SQL_IMPORT_BYTES} bytes" ))); } let scratch = Self::empty()?; scratch.connection.authorizer(Some(import_authorizer)); let result = scratch.connection.execute_batch(sql); scratch.connection.authorizer( None::) -> rusqlite::hooks::Authorization>, ); result.map_err(|error| AppError::Database(format!("execute SQL import: {error}")))?; scratch.finish_input() } fn from_binary(path: &Path) -> Result { let owned_source = snapshot_binary_restore_file(path, MAX_BINARY_RESTORE_BYTES)?; let source = Connection::open_with_flags( owned_source.path(), OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NOFOLLOW | OpenFlags::SQLITE_OPEN_PRIVATE_CACHE, ) .map_err(|error| AppError::Database(error.to_string()))?; let mut scratch = Self::empty()?; { let backup = Backup::new(&source, &mut scratch.connection) .map_err(|error| AppError::Database(error.to_string()))?; run_backup_to_completion(&backup, "clone binary restore into private scratch")?; } drop(source); drop(owned_source); scratch.finish_input() } fn finish_input(self) -> Result { self.enforce_scratch_size()?; self.constrain_scratch_growth()?; let version = Database::get_user_version(&self.connection)?; if version > SCHEMA_VERSION { return Err(AppError::InvalidInput(format!( "restore schema version {version} is newer than supported {SCHEMA_VERSION}" ))); } self.drop_untrusted_executable_objects()?; self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true) .map_err(|error| AppError::Database(error.to_string()))?; Database::create_tables_on_conn(&self.connection, MigrationRunContext::UntrustedRestore)?; Database::apply_schema_migrations_on_conn( &self.connection, MigrationRunContext::UntrustedRestore, )?; self.connection .execute_batch("PRAGMA foreign_keys = ON;") .map_err(|error| AppError::Database(error.to_string()))?; self.enforce_scratch_size()?; // Keep the progress/cancellation handler installed while fixed-column // rows are decoded and copied into the canonical stage. The source is // still untrusted during those SELECTs; dropping the handler here would // leave the data-transfer half of restore outside the VM budget. Ok(self) } /// SQLite's Backup API adopts the source page size but keeps the /// destination's page-count limit as a raw number. A 64 KiB source would /// therefore turn the nominal 2 GiB scratch ceiling into a 32 GiB growth /// allowance unless the limit is rebound after cloning. fn constrain_scratch_growth(&self) -> Result<(), AppError> { let page_size: u64 = self .connection .query_row("PRAGMA page_size", [], |row| row.get(0)) .map_err(|error| AppError::Database(error.to_string()))?; if page_size == 0 { return Err(AppError::InvalidInput( "restore scratch reported a zero page size".to_string(), )); } let byte_bounded_pages = MAX_SCRATCH_BYTES / page_size; let requested = max_page_count().min(byte_bounded_pages); if requested == 0 { return Err(AppError::InvalidInput( "restore scratch page size exceeds the byte budget".to_string(), )); } let applied: u64 = self .connection .query_row(&format!("PRAGMA max_page_count = {requested}"), [], |row| { row.get(0) }) .map_err(|error| AppError::Database(error.to_string()))?; if applied > requested { return Err(AppError::InvalidInput(format!( "restore scratch already exceeds its {requested}-page growth budget" ))); } Ok(()) } fn drop_untrusted_executable_objects(&self) -> Result<(), AppError> { for schema in ["sqlite_schema", "sqlite_temp_schema"] { let mut stmt = self .connection .prepare(&format!( "SELECT type, name FROM {schema} WHERE type IN ('trigger', 'view', 'index') AND sql IS NOT NULL ORDER BY CASE type WHEN 'trigger' THEN 0 WHEN 'view' THEN 1 ELSE 2 END, name" )) .map_err(|error| AppError::Database(error.to_string()))?; let objects = stmt .query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(|error| AppError::Database(error.to_string()))? .collect::, _>>() .map_err(|error| AppError::Database(error.to_string()))?; drop(stmt); for (kind, name) in objects { let keyword = match kind.as_str() { "trigger" => "TRIGGER", "view" => "VIEW", "index" => "INDEX", _ => { return Err(AppError::Database(format!( "unsupported scratch object type '{kind}'" ))) } }; let escaped = name.replace('"', "\"\""); self.connection .execute(&format!("DROP {keyword} IF EXISTS \"{escaped}\""), []) .map_err(|error| AppError::Database(error.to_string()))?; } } Ok(()) } fn enforce_scratch_size(&self) -> Result<(), AppError> { let page_count: u64 = self .connection .query_row("PRAGMA page_count", [], |row| row.get(0)) .map_err(|error| AppError::Database(error.to_string()))?; let page_size: u64 = self .connection .query_row("PRAGMA page_size", [], |row| row.get(0)) .map_err(|error| AppError::Database(error.to_string()))?; let logical_size = page_count.saturating_mul(page_size); let file_size = self ._file .as_file() .metadata() .map_err(|error| AppError::io(self._file.path(), error))? .len(); if logical_size > MAX_SCRATCH_BYTES || file_size > MAX_SCRATCH_BYTES { return Err(AppError::InvalidInput(format!( "restore scratch exceeds {MAX_SCRATCH_BYTES} bytes" ))); } Ok(()) } } fn quoted_columns(spec: &RestoreTableSpec) -> String { spec.columns .iter() .map(|column| format!("\"{}\"", column.name)) .collect::>() .join(", ") } fn validate_storage( table: &str, column: &RestoreColumnSpec, value: &Value, ) -> Result<(), AppError> { let valid = match value { Value::Null => column.nullable, Value::Text(_) => column.storage == StorageKind::Text, Value::Integer(_) => column.storage == StorageKind::Integer, Value::Real(_) => column.storage == StorageKind::Real, Value::Blob(_) => false, }; if valid { Ok(()) } else { Err(AppError::InvalidInput(format!( "restore row has invalid storage class for {table}.{}", column.name ))) } } fn validate_integer_domain( table: &str, column: &RestoreColumnSpec, value: &Value, ) -> Result<(), AppError> { let Value::Integer(value) = value else { return Ok(()); }; let valid = match column.integer_domain { IntegerDomain::Unrestricted => true, IntegerDomain::Boolean => matches!(*value, 0 | 1), IntegerDomain::NonNegative => *value >= 0, IntegerDomain::SortIndex => { (0..i64::MAX).contains(value) && usize::try_from(*value).is_ok() } IntegerDomain::Unsigned8 => (0..=u8::MAX as i64).contains(value), IntegerDomain::Unsigned16 => (0..=u16::MAX as i64).contains(value), IntegerDomain::NonNegativeI32 => (0..=i32::MAX as i64).contains(value), IntegerDomain::Unsigned32 => (0..=u32::MAX as i64).contains(value), IntegerDomain::InputTokenSemantics => (0..=2).contains(value), }; if valid { Ok(()) } else { Err(AppError::InvalidInput(format!( "restore row has out-of-domain integer {value} at {table}.{} ({:?})", column.name, column.integer_domain ))) } } fn validate_real_domain( table: &str, column: &RestoreColumnSpec, value: &Value, ) -> Result<(), AppError> { let Value::Real(value) = value else { return Ok(()); }; let valid = match column.real_domain { RealDomain::NotReal => false, RealDomain::FiniteUnitInterval => value.is_finite() && (0.0..=1.0).contains(value), }; if valid { Ok(()) } else { Err(AppError::InvalidInput(format!( "restore row has out-of-domain real {value} at {table}.{} ({:?})", column.name, column.real_domain ))) } } fn text_value<'a>(table: &str, column: &str, value: &'a Value) -> Result<&'a str, AppError> { match value { Value::Text(value) => Ok(value), _ => Err(AppError::InvalidInput(format!( "restore row requires text at {table}.{column}" ))), } } fn validate_json_text(table: &str, column: &str, value: &Value) -> Result<(), AppError> { let text = text_value(table, column, value)?; serde_json::from_str::(text) .map(|_| ()) .map_err(|error| { AppError::InvalidInput(format!( "restore row has invalid JSON at {table}.{column}: {error}" )) }) } fn validate_non_negative_decimal(table: &str, column: &str, value: &Value) -> Result<(), AppError> { let value = text_value(table, column, value)?; let parsed = value.parse::().map_err(|error| { AppError::InvalidInput(format!( "restore row has invalid decimal at {table}.{column}: {error}" )) })?; if parsed < rust_decimal::Decimal::ZERO { return Err(AppError::InvalidInput(format!( "restore row has negative decimal at {table}.{column}: {value}" ))); } Ok(()) } fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), AppError> { if values.len() != spec.columns.len() { return Err(AppError::Database(format!( "restore decoder width mismatch for '{}'", spec.name ))); } for (column, value) in spec.columns.iter().zip(values) { validate_storage(spec.name, column, value)?; validate_integer_domain(spec.name, column, value)?; validate_real_domain(spec.name, column, value)?; } match spec.validator { RestoreRowValidator::OpaqueStorage => {} RestoreRowValidator::Provider => { let id = text_value(spec.name, "id", &values[0])?; let app_type = text_value(spec.name, "app_type", &values[1])?; let settings = text_value(spec.name, "settings_config", &values[3])?; let meta = text_value(spec.name, "meta", &values[11])?; crate::database::dao::providers::validate_provider_storage_json( app_type, id, settings, meta, )?; crate::database::validate_cost_multiplier(text_value( spec.name, "cost_multiplier", &values[14], )?)?; for index in [15_usize, 16] { if !matches!(values[index], Value::Null) { validate_non_negative_decimal( spec.name, spec.columns[index].name, &values[index], )?; } } } RestoreRowValidator::Mcp => { validate_json_text(spec.name, "server_config", &values[2])?; serde_json::from_str::>(text_value(spec.name, "tags", &values[6])?) .map(|_| ()) .map_err(|error| { AppError::InvalidInput(format!( "restore row has invalid MCP tags at {}.tags: {error}", spec.name )) })?; } RestoreRowValidator::Profile => { serde_json::from_str::(text_value( spec.name, "payload", &values[2], )?) .map(|_| ()) .map_err(|error| { AppError::InvalidInput(format!( "restore row has invalid ProfilePayload at {}.payload: {error}", spec.name )) })?; } RestoreRowValidator::ProxyConfig => { crate::database::validate_cost_multiplier(text_value( spec.name, "default_cost_multiplier", &values[16], )?)?; crate::database::validate_pricing_source(text_value( spec.name, "pricing_model_source", &values[17], )?)?; } RestoreRowValidator::NonNegativeDecimalColumns(indices) => { for index in indices { validate_non_negative_decimal( spec.name, spec.columns[*index].name, &values[*index], )?; } } } Ok(()) } fn is_device_local_setting_key(key: &str) -> bool { DEVICE_LOCAL_SETTING_KEYS.contains(&key) } fn copy_fixed_table( source: &Connection, target: &Connection, spec: &RestoreTableSpec, clear_target: bool, ) -> Result<(), AppError> { if clear_target { target .execute(&format!("DELETE FROM \"{}\"", spec.name), []) .map_err(|error| { AppError::Database(format!("clear canonical table '{}': {error}", spec.name)) })?; } let columns = quoted_columns(spec); let select = format!("SELECT {columns} FROM \"{}\"", spec.name); let placeholders = (1..=spec.columns.len()) .map(|index| format!("?{index}")) .collect::>() .join(", "); let insert = format!( "INSERT INTO \"{}\" ({columns}) VALUES ({placeholders})", spec.name ); let mut statement = source.prepare(&select).map_err(|error| { AppError::InvalidInput(format!( "restore source is missing a fixed column in '{}': {error}", spec.name )) })?; let mut rows = statement .query([]) .map_err(|error| AppError::Database(error.to_string()))?; while let Some(row) = rows .next() .map_err(|error| AppError::Database(error.to_string()))? { let values = (0..spec.columns.len()) .map(|index| row.get::<_, Value>(index)) .collect::, _>>() .map_err(|error| AppError::InvalidInput(error.to_string()))?; if spec.name == "settings" && values .first() .and_then(|value| match value { Value::Text(key) => Some(key.as_str()), _ => None, }) .is_some_and(is_device_local_setting_key) { continue; } validate_restore_row(spec, &values)?; target .execute(&insert, rusqlite::params_from_iter(values.iter())) .map_err(|error| { AppError::InvalidInput(format!( "canonical insert into '{}' failed: {error}", spec.name )) })?; } Ok(()) } fn copy_live_device_settings(source: &Connection, target: &Connection) -> Result<(), AppError> { for key in DEVICE_LOCAL_SETTING_KEYS { let value = source .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { row.get::<_, Option>(0) }) .optional() .map_err(|error| AppError::Database(error.to_string()))? .flatten(); if let Some(value) = value { target .execute( "INSERT INTO settings (key, value) VALUES (?1, ?2)", rusqlite::params![key, value], ) .map_err(|error| { AppError::Database(format!( "copy device-local setting at restore boundary: {error}" )) })?; } } Ok(()) } fn assert_restore_policy_topology() -> Result<(), AppError> { let mut seen = std::collections::BTreeSet::new(); for spec in RESTORE_TABLE_SPECS { if !seen.insert(spec.name) { return Err(AppError::Database(format!( "duplicate restore policy for '{}'", spec.name ))); } for column in spec.columns { if column.storage != StorageKind::Integer && column.integer_domain != IntegerDomain::Unrestricted { return Err(AppError::Database(format!( "non-integer restore column '{}.{}' declares an integer domain", spec.name, column.name ))); } if column.storage == StorageKind::Real && column.real_domain == RealDomain::NotReal { return Err(AppError::Database(format!( "real restore column '{}.{}' has no finite domain", spec.name, column.name ))); } if column.storage != StorageKind::Real && column.real_domain != RealDomain::NotReal { return Err(AppError::Database(format!( "non-real restore column '{}.{}' declares a real domain", spec.name, column.name ))); } } for parent in spec.parents { if !seen.contains(parent) { return Err(AppError::Database(format!( "restore policy '{}' precedes parent '{}'", spec.name, parent ))); } } } Ok(()) } fn canonical_user_tables( conn: &Connection, ) -> Result, AppError> { let mut statement = conn .prepare( "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", ) .map_err(|error| AppError::Database(error.to_string()))?; let tables = statement .query_map([], |row| row.get::<_, String>(0)) .map_err(|error| AppError::Database(error.to_string()))? .collect::, _>>() .map_err(|error| AppError::Database(error.to_string()))?; Ok(tables) } fn assert_restore_policy_coverage( canonical_tables: &std::collections::BTreeSet, policy_tables: &std::collections::BTreeSet, ) -> Result<(), AppError> { if canonical_tables == policy_tables { Ok(()) } else { let missing_policy = canonical_tables .difference(policy_tables) .cloned() .collect::>(); let stale_policy = policy_tables .difference(canonical_tables) .cloned() .collect::>(); Err(AppError::Database(format!( "canonical user tables and restore policy manifest differ; \ missing policy={missing_policy:?}, stale policy={stale_policy:?}" ))) } } fn validate_stage_rows(conn: &Connection) -> Result<(), AppError> { for spec in RESTORE_TABLE_SPECS { let columns = quoted_columns(spec); let mut statement = conn .prepare(&format!("SELECT {columns} FROM \"{}\"", spec.name)) .map_err(|error| AppError::Database(error.to_string()))?; let mut rows = statement .query([]) .map_err(|error| AppError::Database(error.to_string()))?; while let Some(row) = rows .next() .map_err(|error| AppError::Database(error.to_string()))? { let values = (0..spec.columns.len()) .map(|index| row.get::<_, Value>(index)) .collect::, _>>() .map_err(|error| AppError::InvalidInput(error.to_string()))?; validate_restore_row(spec, &values)?; } } Ok(()) } fn validate_canonical_behaviors(conn: &Connection) -> Result<(), AppError> { let transaction = conn .unchecked_transaction() .map_err(|error| AppError::Database(error.to_string()))?; let endpoint_max: i64 = transaction .query_row( "SELECT COALESCE(MAX(id), 0) FROM provider_endpoints", [], |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; let (probe_upper, probe_lower, probe_app) = loop { let suffix = uuid::Uuid::new_v4().simple().to_string(); let upper = format!("__restore_A{suffix}"); let lower = format!("__restore_a{suffix}"); let app = format!("__restore_probe_{suffix}"); let existing: i64 = transaction .query_row( "SELECT COUNT(*) FROM providers WHERE app_type = ?1 AND id IN (?2, ?3)", rusqlite::params![app, upper, lower], |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; if existing == 0 { break (upper, lower, app); } }; transaction .execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES (?1, ?2, 'probe', '{}', '{}')", rusqlite::params![probe_upper, probe_app], ) .map_err(|error| AppError::Database(error.to_string()))?; transaction .execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES (?1, ?2, 'probe', '{}', '{}')", rusqlite::params![probe_lower, probe_app], ) .map_err(|error| { AppError::Database(format!( "canonical provider key is not BINARY case-sensitive: {error}" )) })?; transaction .execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) VALUES (?1, ?2, 'https://probe.invalid', NULL, NULL)", rusqlite::params![probe_upper, probe_app], ) .map_err(|error| AppError::Database(error.to_string()))?; let endpoint_id = transaction.last_insert_rowid(); if endpoint_id <= endpoint_max { return Err(AppError::Database( "provider_endpoints AUTOINCREMENT did not advance past explicit restored IDs" .to_string(), )); } if transaction .execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES (?1, ?2, 'replacement', '{}', '{}')", rusqlite::params![probe_upper, probe_app], ) .is_ok() { return Err(AppError::Database( "canonical provider duplicate policy is not ABORT".to_string(), )); } let endpoint_count: i64 = transaction .query_row( "SELECT COUNT(*) FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2", rusqlite::params![probe_upper, probe_app], |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; if endpoint_count != 1 { return Err(AppError::Database( "duplicate provider insert replaced or cascaded endpoint rows".to_string(), )); } let stream_max: i64 = transaction .query_row( "SELECT COALESCE(MAX(id), 0) FROM stream_check_logs", [], |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; transaction .execute( "INSERT INTO stream_check_logs (provider_id, provider_name, app_type, status, success, message, tested_at) VALUES ('__probe', 'probe', '__probe', 'ok', 1, 'ok', 1)", [], ) .map_err(|error| AppError::Database(error.to_string()))?; if transaction.last_insert_rowid() <= stream_max { return Err(AppError::Database( "stream_check_logs AUTOINCREMENT did not advance past explicit restored IDs" .to_string(), )); } transaction .rollback() .map_err(|error| AppError::Database(error.to_string())) } fn validate_canonical_stage(stage: &CanonicalStage) -> Result<(), AppError> { let conn = stage.connection(); if Database::get_user_version(conn)? != SCHEMA_VERSION { return Err(AppError::Database( "canonical stage has an unexpected schema version".to_string(), )); } assert_restore_policy_topology()?; let policy_tables = RESTORE_TABLE_SPECS .iter() .map(|spec| spec.name.to_string()) .collect::>(); assert_restore_policy_coverage(&canonical_user_tables(conn)?, &policy_tables)?; validate_stage_rows(conn)?; let integrity: String = conn .query_row("PRAGMA integrity_check(1)", [], |row| row.get(0)) .map_err(|error| AppError::Database(error.to_string()))?; if integrity != "ok" { return Err(AppError::Database(format!( "canonical integrity_check failed: {integrity}" ))); } let mut foreign_keys = conn .prepare("PRAGMA foreign_key_check") .map_err(|error| AppError::Database(error.to_string()))?; if foreign_keys .query([]) .map_err(|error| AppError::Database(error.to_string()))? .next() .map_err(|error| AppError::Database(error.to_string()))? .is_some() { return Err(AppError::Database( "canonical foreign_key_check failed".to_string(), )); } drop(foreign_keys); validate_canonical_behaviors(conn) } /// 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, DEVICE_LOCAL_TABLES) } /// 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 { let bytes = read_restore_file(source_path, MAX_SQL_IMPORT_BYTES)?; let sql = std::str::from_utf8(&bytes).map_err(|error| { AppError::InvalidInput(format!( "SQL restore source is not UTF-8 ({}): {error}", source_path.display() )) })?; self.import_sql_string(sql) } /// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串) pub fn import_sql_string(&self, sql_raw: &str) -> Result { self.import_sql_string_inner(sql_raw, RestoreFlavor::UserRestore) } /// 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, RestoreFlavor::Sync) } fn import_sql_string_inner( &self, sql_raw: &str, flavor: RestoreFlavor, ) -> Result { let sql_content = sql_raw.trim_start_matches('\u{feff}'); Self::validate_cc_switch_sql_export(sql_content)?; let scratch = UntrustedScratch::from_sql(sql_content)?; let stage = Self::build_canonical_stage(&scratch)?; let backup_path = self.publish_canonical_stage(stage, flavor)?; 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()))?; run_backup_to_completion(&backup, "snapshot live database into memory")?; } Ok(snapshot) } fn build_canonical_stage(scratch: &UntrustedScratch) -> Result { let mut stage = Self::current_canonical_stage()?; stage .connection() .execute_batch("PRAGMA foreign_keys = ON;") .map_err(|error| AppError::Database(error.to_string()))?; let transaction = stage .connection_mut() .transaction() .map_err(|error| AppError::Database(error.to_string()))?; // The factory may create canonical seed rows. Clear every non-seed // table child-first so incoming rows are always inserted with plain // INSERT into a clean target. for spec in RESTORE_TABLE_SPECS.iter().rev() { if spec.policy != RestorePolicy::SeedCanonical { transaction .execute(&format!("DELETE FROM \"{}\"", spec.name), []) .map_err(|error| AppError::Database(error.to_string()))?; } } for spec in RESTORE_TABLE_SPECS { if spec.policy == RestorePolicy::PortableIncoming { copy_fixed_table(&scratch.connection, &transaction, spec, false)?; } } transaction .commit() .map_err(|error| AppError::Database(error.to_string()))?; validate_canonical_stage(&stage)?; Ok(stage) } /// Freeze live writes before the safety snapshot and keep the same /// connection guard through publication. This is the sole publish /// boundary: it consumes only a schema-factory-owned CanonicalStage, and no /// helper accepts a raw Connection as a publishable source. fn publish_canonical_stage( &self, mut stage: CanonicalStage, flavor: RestoreFlavor, ) -> Result, AppError> { let mut main_conn = lock_conn!(self.conn); let safety_backup = Self::backup_database_file_on_locked_connection(&main_conn)?; #[cfg(test)] run_after_safety_backup_test_seam(); validate_canonical_stage(&stage)?; { let transaction = stage .connection_mut() .transaction() .map_err(|error| AppError::Database(error.to_string()))?; for spec in RESTORE_TABLE_SPECS { let preserve = spec.policy == RestorePolicy::PreserveLive || (flavor == RestoreFlavor::Sync && SYNC_LIVE_OVERLAY_TABLES.contains(&spec.name)); if preserve { copy_fixed_table(&main_conn, &transaction, spec, true)?; } } copy_live_device_settings(&main_conn, &transaction)?; transaction .commit() .map_err(|error| AppError::Database(error.to_string()))?; } validate_canonical_stage(&stage)?; let backup = Backup::new(stage.connection(), &mut main_conn) .map_err(|error| AppError::Database(error.to_string()))?; run_backup_to_completion(&backup, "publish canonical restore stage")?; Ok(safety_backup) } 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.", )) } /// 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 conn = lock_conn!(self.conn); Self::backup_database_file_on_locked_connection(&conn) } fn backup_database_file_on_locked_connection( conn: &Connection, ) -> 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 result = (|| { 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()))?; run_backup_to_completion(&backup, "create database safety backup")?; } Ok(()) })(); if let Err(error) = result { if let Err(remove_error) = fs::remove_file(&backup_path) { if remove_error.kind() != std::io::ErrorKind::NotFound { log::warn!( "failed to remove incomplete safety backup '{}': {remove_error}", backup_path.display() ); } } return Err(error); } let completed = open_nofollow(&backup_path).map_err(|error| AppError::io(&backup_path, error))?; Self::cleanup_db_backups(&backup_dir, Some(&backup_path))?; let still_current = open_nofollow(&backup_path).map_err(|error| AppError::io(&backup_path, error))?; if !still_current .metadata() .map_err(|error| AppError::io(&backup_path, error))? .file_type() .is_file() || !same_open_file_identity(&completed, &still_current) .map_err(|error| AppError::io(&backup_path, error))? { return Err(AppError::InvalidInput( "completed safety backup changed during retention cleanup".to_string(), )); } Ok(Some(backup_path)) } /// 清理旧的数据库备份,保留最新的 N 个 fn cleanup_db_backups(dir: &Path, protected: Option<&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 .into_iter() .filter(|entry| protected.is_none_or(|path| entry.path() != path)) .collect::>(); 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(()) } /// 导出数据库为 SQL 文本 fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result { let mut device_local_secrets = Vec::new(); let has_settings: bool = conn .query_row( "SELECT EXISTS( SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'settings' )", [], |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; if has_settings { for key in DEVICE_LOCAL_SETTING_KEYS { if let Some(value) = conn .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { row.get::<_, Option>(0) }) .optional() .map_err(|error| AppError::Database(error.to_string()))? .flatten() .filter(|value| !value.is_empty()) { device_local_secrets.push(value); } } } 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()))? { if table == "settings" && row .get_ref(0) .ok() .and_then(|value| match value { ValueRef::Text(key) => std::str::from_utf8(key).ok(), _ => None, }) .is_some_and(is_device_local_setting_key) { continue; } 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"); if DEVICE_LOCAL_SETTING_KEYS .iter() .any(|key| output.contains(key)) || device_local_secrets .iter() .any(|secret| output.contains(secret)) { return Err(AppError::Database( "portable SQL export contained a device-local credential".to_string(), )); } 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 { validate_backup_filename(filename)?; let backup_dir = get_app_config_dir().join("backups"); validate_backup_directory(&backup_dir)?; let backup_path = backup_dir.join(filename); // Build a canonical data-only stage before touching the live database. let scratch = UntrustedScratch::from_binary(&backup_path)?; let stage = Self::build_canonical_stage(&scratch)?; // The live connection guard is acquired before the safety snapshot and // remains held through publish, closing the write-loss window. let safety_backup = self.publish_canonical_stage(stage, RestoreFlavor::UserRestore)?; let safety_id = safety_backup .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); 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_backup_filename(old_filename)?; // 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('\0') || name_part.contains(':') { return Err(AppError::InvalidInput( "Invalid characters in new name".to_string(), )); } let new_filename = format!("{name_part}.db"); validate_backup_filename(&new_filename)?; let backup_dir = get_app_config_dir().join("backups"); validate_backup_directory(&backup_dir)?; 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_backup_filename(filename)?; let backup_dir = get_app_config_dir().join("backups"); validate_backup_directory(&backup_dir)?; let backup_path = backup_dir.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::{ assert_restore_policy_coverage, validate_canonical_behaviors, validate_regular_file, validate_stage_rows, Database, MigrationRunContext, RestoreFlavor, RestorePolicy, RestoreRowValidator, StorageKind, UntrustedScratch, MAX_BINARY_RESTORE_BYTES, MAX_SCRATCH_BYTES, MAX_SQL_IMPORT_BYTES, RESTORE_TABLE_SPECS, SCHEMA_VERSION, TEST_MAX_BACKUP_TRANSIENT_RETRIES, TEST_MAX_PAGE_COUNT, TEST_MAX_VM_STEPS, }; use crate::error::AppError; use crate::settings::{get_settings, update_settings, AppSettings}; use rusqlite::backup::Backup; use rusqlite::Connection; use serial_test::serial; use sha2::{Digest, Sha256}; use std::ffi::OsString; use std::fs::{self, File}; #[derive(Clone, Copy)] enum RestoreEntryPoint { Sql, Binary, } struct TestHomeGuard(Option); impl TestHomeGuard { fn set(path: &std::path::Path) -> Self { let previous = std::env::var_os("CC_SWITCH_TEST_HOME"); std::env::set_var("CC_SWITCH_TEST_HOME", path); Self(previous) } } impl Drop for TestHomeGuard { fn drop(&mut self) { match self.0.take() { Some(previous) => std::env::set_var("CC_SWITCH_TEST_HOME", previous), None => std::env::remove_var("CC_SWITCH_TEST_HOME"), } } } struct RestoreLimitGuard { previous_vm_steps: Option, previous_page_count: Option, } impl RestoreLimitGuard { fn set(vm_steps: Option, page_count: Option) -> Self { let previous_vm_steps = TEST_MAX_VM_STEPS.with(|current| current.replace(vm_steps)); let previous_page_count = TEST_MAX_PAGE_COUNT.with(|current| current.replace(page_count)); Self { previous_vm_steps, previous_page_count, } } } impl Drop for RestoreLimitGuard { fn drop(&mut self) { TEST_MAX_VM_STEPS.with(|current| current.set(self.previous_vm_steps)); TEST_MAX_PAGE_COUNT.with(|current| current.set(self.previous_page_count)); } } struct BackupRetryGuard(Option); impl BackupRetryGuard { fn set(limit: u32) -> Self { Self(TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(|current| current.replace(Some(limit)))) } } impl Drop for BackupRetryGuard { fn drop(&mut self) { TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(|current| current.set(self.0)); } } struct AppSettingsGuard(AppSettings); impl AppSettingsGuard { fn replace(settings: AppSettings) -> Result { let previous = get_settings(); update_settings(settings)?; Ok(Self(previous)) } } impl Drop for AppSettingsGuard { fn drop(&mut self) { if let Err(error) = update_settings(self.0.clone()) { log::error!("failed to restore test settings: {error}"); } } } fn restore_policy_snapshot() -> serde_json::Value { let full_specs = RESTORE_TABLE_SPECS .iter() .map(|spec| { let policy = match spec.policy { RestorePolicy::PortableIncoming => "portable_incoming", RestorePolicy::PreserveLive => "preserve_live", RestorePolicy::RebuildRuntime => "rebuild_runtime", RestorePolicy::SeedCanonical => "seed_canonical", }; let validator = match spec.validator { RestoreRowValidator::OpaqueStorage => { serde_json::json!("opaque_storage") } RestoreRowValidator::Provider => serde_json::json!("provider"), RestoreRowValidator::Mcp => serde_json::json!("mcp"), RestoreRowValidator::Profile => serde_json::json!("profile"), RestoreRowValidator::ProxyConfig => serde_json::json!("proxy_config"), RestoreRowValidator::NonNegativeDecimalColumns(indices) => { serde_json::json!({"nonNegativeDecimalColumns": indices}) } }; serde_json::json!({ "name": spec.name, "policy": policy, "columns": spec.columns.iter().map(|column| { let storage = match column.storage { StorageKind::Text => "text", StorageKind::Integer => "integer", StorageKind::Real => "real", }; let integer_domain = match column.integer_domain { super::IntegerDomain::Unrestricted => "unrestricted", super::IntegerDomain::Boolean => "boolean", super::IntegerDomain::NonNegative => "non_negative", super::IntegerDomain::SortIndex => "sort_index", super::IntegerDomain::Unsigned8 => "unsigned_8", super::IntegerDomain::Unsigned16 => "unsigned_16", super::IntegerDomain::NonNegativeI32 => "non_negative_i32", super::IntegerDomain::Unsigned32 => "unsigned_32", super::IntegerDomain::InputTokenSemantics => { "input_token_semantics" } }; let real_domain = match column.real_domain { super::RealDomain::NotReal => "not_real", super::RealDomain::FiniteUnitInterval => "finite_unit_interval", }; serde_json::json!([ column.name, storage, column.nullable, integer_domain, real_domain ]) }).collect::>(), "validator": validator, "parents": spec.parents, }) }) .collect::>(); let digest = Sha256::digest( serde_json::to_vec(&full_specs).expect("serialize restore policy authority"), ); let tables = full_specs .iter() .map(|spec| { serde_json::json!({ "name": spec["name"], "policy": spec["policy"], }) }) .collect::>(); serde_json::json!({ "manifestVersion": 1, "codeAuthority": "src-tauri/src/database/backup.rs", "schemaVersion": SCHEMA_VERSION, "limits": { "sqlImportBytes": MAX_SQL_IMPORT_BYTES, "binaryRestoreBytes": MAX_BINARY_RESTORE_BYTES, "scratchBytes": MAX_SCRATCH_BYTES, }, "specSha256": format!("{digest:x}"), "tables": tables, }) } #[test] fn restore_policy_snapshot_is_exhaustive_and_detects_missing_table_fixture( ) -> Result<(), AppError> { let expected: serde_json::Value = serde_json::from_str(include_str!( "../../../tests/fixtures/pi/restore-policy-v1.json" )) .expect("parse restore policy snapshot"); assert_eq!(restore_policy_snapshot(), expected); let canonical = Database::current_canonical_stage()?; let canonical_tables = super::canonical_user_tables(canonical.connection())?; let policy_tables = RESTORE_TABLE_SPECS .iter() .map(|spec| spec.name.to_string()) .collect::>(); assert_restore_policy_coverage(&canonical_tables, &policy_tables)?; let mut negative_fixture = canonical_tables; negative_fixture.insert("future_table_without_policy".to_string()); let error = assert_restore_policy_coverage(&negative_fixture, &policy_tables) .expect_err("a new table without a policy must fail the scanner"); assert!(error.to_string().contains("future_table_without_policy")); Ok(()) } fn canonical_restore_source() -> Result { 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 ('remote-provider', 'pi', 'Remote Provider', '{}', '{}')", [], )?; } source.snapshot_to_memory() } fn actual_v16_duplicate_endpoint_source() -> Result { let source = canonical_restore_source()?; source.execute_batch( "PRAGMA foreign_keys = OFF; DROP TABLE provider_endpoints; CREATE TABLE provider_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER, FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE ); INSERT INTO provider_endpoints (id, provider_id, app_type, url, added_at) VALUES (1601, 'remote-provider', 'pi', 'https://duplicate-v16.invalid', 20), (1602, 'remote-provider', 'pi', 'https://duplicate-v16.invalid', 10); DROP TABLE pi_provider_projections; DROP TABLE skill_deployments; ALTER TABLE skills DROP COLUMN enabled_pi; PRAGMA user_version = 16;", )?; Ok(source) } fn weak_ledger_source() -> Result { let source = canonical_restore_source()?; source.execute_batch( "PRAGMA foreign_keys = OFF; DROP TABLE pi_provider_projections; DROP TABLE skill_deployments; CREATE TABLE pi_provider_projections ( provider_id TEXT, provider_key TEXT, created_at INTEGER, updated_at INTEGER ); CREATE UNIQUE INDEX remote_projection_alternate ON pi_provider_projections(updated_at); CREATE TABLE skill_deployments ( app_type TEXT, skill_id TEXT, destination TEXT, destination_key TEXT, method TEXT, source_identity TEXT, deployed_digest TEXT, created_at INTEGER, updated_at INTEGER ); CREATE UNIQUE INDEX remote_skill_alternate ON skill_deployments(skill_id); INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('remote-provider', 'remote-key', 10, 10); INSERT INTO skill_deployments ( app_type, skill_id, destination, destination_key, method, source_identity, created_at, updated_at ) VALUES ( 'codex', 'remote-skill', '/remote', '/remote', 'move', 'remote-source', 10, 10 ); PRAGMA foreign_keys = ON;", )?; Ok(source) } fn weak_endpoint_source() -> Result { let source = canonical_restore_source()?; source.execute_batch( "PRAGMA foreign_keys = OFF; DROP TABLE provider_endpoints; CREATE TABLE provider_endpoints ( id INTEGER PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER, last_used INTEGER ); INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES ('remote-provider', 'pi', 'https://weak.test', 1); PRAGMA foreign_keys = ON;", )?; Ok(source) } fn seed_local_ledgers(target: &Database) -> Result<(), AppError> { let conn = crate::database::lock_conn!(target.conn); conn.execute( "INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('local-provider', 'local-key', 20, 20)", [], )?; conn.execute( "INSERT INTO skill_deployments ( app_type, skill_id, destination, destination_key, method, source_identity, created_at, updated_at ) VALUES ( 'pi', 'local-skill', '/local', '/local', 'copy', 'local-source', 20, 20 )", [], )?; Ok(()) } fn run_restore_entry( target: &Database, source: &Connection, entry_point: RestoreEntryPoint, filename: &str, ) -> Result { match entry_point { RestoreEntryPoint::Sql => { let sql = Database::dump_sql(source, &[])?; target.import_sql_string(&sql) } RestoreEntryPoint::Binary => { let backup_dir = crate::config::get_app_config_dir().join("backups"); std::fs::create_dir_all(&backup_dir) .map_err(|error| AppError::io(&backup_dir, error))?; let backup_path = backup_dir.join(filename); let mut destination = Connection::open(&backup_path)?; { let backup = Backup::new(source, &mut destination)?; super::run_backup_to_completion(&backup, "prepare binary restore fixture")?; } drop(destination); target.restore_from_backup(filename) } } } fn logical_snapshot(target: &Database) -> Result { let snapshot = target.snapshot_to_memory()?; let dump = Database::dump_sql(&snapshot, &[])?; Ok(dump .lines() .filter(|line| !line.starts_with("-- 生成时间:")) .collect::>() .join("\n")) } const WEAK_PROVIDERS_NO_KEY: &str = "CREATE TABLE providers ( id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, website_url TEXT, category TEXT, created_at INTEGER, sort_index INTEGER, notes TEXT, icon TEXT, icon_color TEXT, meta TEXT NOT NULL DEFAULT '{}', is_current BOOLEAN NOT NULL DEFAULT 0, in_failover_queue BOOLEAN NOT NULL DEFAULT 0, cost_multiplier TEXT NOT NULL DEFAULT '1.0', limit_daily_usd TEXT, limit_monthly_usd TEXT, provider_type TEXT )"; const HOSTILE_PROVIDERS_NOCASE_REPLACE: &str = "CREATE TABLE providers ( id TEXT COLLATE NOCASE NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, website_url TEXT, category TEXT, created_at INTEGER, sort_index INTEGER, notes TEXT, icon TEXT, icon_color TEXT, meta TEXT NOT NULL DEFAULT '{}', is_current BOOLEAN NOT NULL DEFAULT 0, in_failover_queue BOOLEAN NOT NULL DEFAULT 0, cost_multiplier TEXT NOT NULL DEFAULT '1.0', limit_daily_usd TEXT, limit_monthly_usd TEXT, provider_type TEXT, PRIMARY KEY (id, app_type) ON CONFLICT REPLACE )"; const WEAK_PROVIDER_ENDPOINTS: &str = "CREATE TABLE provider_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER, last_used INTEGER )"; fn replace_schema_object( exported: &mut String, source: &Connection, object_type: &str, name: &str, replacement: &str, ) -> Result<(), AppError> { let original: String = source.query_row( "SELECT sql FROM sqlite_schema WHERE type = ?1 AND name = ?2", rusqlite::params![object_type, name], |row| row.get(0), )?; let needle = format!("{original};"); if !exported.contains(&needle) { return Err(AppError::Database(format!( "test export did not contain schema object {object_type}/{name}" ))); } *exported = exported.replacen(&needle, &format!("{replacement};"), 1); Ok(()) } fn rewritten_source( provider_ddl: Option<&str>, endpoint_ddl: Option<&str>, ) -> Result { let base = canonical_restore_source()?; let mut exported = Database::dump_sql(&base, &[])?; if let Some(definition) = provider_ddl { replace_schema_object(&mut exported, &base, "table", "providers", definition)?; } if let Some(definition) = endpoint_ddl { replace_schema_object( &mut exported, &base, "table", "provider_endpoints", definition, )?; } let source = Connection::open_in_memory()?; source.execute_batch(&exported)?; Ok(source) } fn seed_live_restore_state(target: &Database) -> Result<(), AppError> { let conn = crate::database::lock_conn!(target.conn); conn.execute_batch( "INSERT INTO providers (id, app_type, name, settings_config, meta, in_failover_queue) VALUES ('live-provider', 'pi', 'Live Provider', '{}', '{}', 1); INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) VALUES ('live-provider', 'pi', 'https://live.invalid', NULL, NULL); INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('live-provider', 'live-key', 20, 20); INSERT INTO skill_deployments ( app_type, skill_id, destination, destination_key, method, source_identity, created_at, updated_at ) VALUES ( 'pi', 'live-skill', '/live', '/live', 'copy', 'live-source', 20, 20 );", )?; Ok(()) } #[derive(Debug, Clone, Copy)] enum InvalidRestoreCase { ProviderJson, McpTagsShape, ProfilePayloadShape, StorageClass, NegativeSortIndex, MaxSortIndex, BooleanDomain, Unsigned8Domain, Unsigned16Domain, NonNegativeI32Domain, Unsigned32Domain, InputTokenSemanticsDomain, NegativeCircuitThreshold, OutOfRangeCircuitThreshold, NonFiniteCircuitThreshold, NegativeProviderMultiplier, NegativeProviderMetaLimit, InvalidProviderPricingSource, NegativeProxyMultiplier, InvalidProxyPricingSource, NegativeModelPrice, DuplicateProvider, DuplicateEndpoint, ForeignKeyOrphan, FutureVersion, } impl InvalidRestoreCase { const ALL: [Self; 25] = [ Self::ProviderJson, Self::McpTagsShape, Self::ProfilePayloadShape, Self::StorageClass, Self::NegativeSortIndex, Self::MaxSortIndex, Self::BooleanDomain, Self::Unsigned8Domain, Self::Unsigned16Domain, Self::NonNegativeI32Domain, Self::Unsigned32Domain, Self::InputTokenSemanticsDomain, Self::NegativeCircuitThreshold, Self::OutOfRangeCircuitThreshold, Self::NonFiniteCircuitThreshold, Self::NegativeProviderMultiplier, Self::NegativeProviderMetaLimit, Self::InvalidProviderPricingSource, Self::NegativeProxyMultiplier, Self::InvalidProxyPricingSource, Self::NegativeModelPrice, Self::DuplicateProvider, Self::DuplicateEndpoint, Self::ForeignKeyOrphan, Self::FutureVersion, ]; fn label(self) -> &'static str { match self { Self::ProviderJson => "provider-json", Self::McpTagsShape => "mcp-tags-shape", Self::ProfilePayloadShape => "profile-payload-shape", Self::StorageClass => "storage-class", Self::NegativeSortIndex => "negative-sort-index", Self::MaxSortIndex => "max-sort-index", Self::BooleanDomain => "boolean-domain", Self::Unsigned8Domain => "unsigned-8-domain", Self::Unsigned16Domain => "unsigned-16-domain", Self::NonNegativeI32Domain => "non-negative-i32-domain", Self::Unsigned32Domain => "unsigned-32-domain", Self::InputTokenSemanticsDomain => "input-token-semantics-domain", Self::NegativeCircuitThreshold => "negative-circuit-threshold", Self::OutOfRangeCircuitThreshold => "out-of-range-circuit-threshold", Self::NonFiniteCircuitThreshold => "non-finite-circuit-threshold", Self::NegativeProviderMultiplier => "negative-provider-multiplier", Self::NegativeProviderMetaLimit => "negative-provider-meta-limit", Self::InvalidProviderPricingSource => "invalid-provider-pricing-source", Self::NegativeProxyMultiplier => "negative-proxy-multiplier", Self::InvalidProxyPricingSource => "invalid-proxy-pricing-source", Self::NegativeModelPrice => "negative-model-price", Self::DuplicateProvider => "duplicate-provider", Self::DuplicateEndpoint => "duplicate-endpoint", Self::ForeignKeyOrphan => "foreign-key-orphan", Self::FutureVersion => "future-version", } } } fn invalid_restore_source(case: InvalidRestoreCase) -> Result { let source = match case { InvalidRestoreCase::DuplicateProvider => { rewritten_source(Some(WEAK_PROVIDERS_NO_KEY), None)? } InvalidRestoreCase::DuplicateEndpoint => { rewritten_source(None, Some(WEAK_PROVIDER_ENDPOINTS))? } _ => canonical_restore_source()?, }; match case { InvalidRestoreCase::ProviderJson => { source.execute( "UPDATE providers SET settings_config = '{' WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::McpTagsShape => { source.execute( "INSERT INTO mcp_servers (id, name, server_config, tags) VALUES ('invalid-tags', 'Invalid Tags', '{}', '{}')", [], )?; } InvalidRestoreCase::ProfilePayloadShape => { source.execute( "INSERT INTO profiles (id, name, payload) VALUES ( 'invalid-profile', 'Invalid Profile', '{\"providers\":[1]}' )", [], )?; } InvalidRestoreCase::StorageClass => { source.execute( "UPDATE providers SET created_at = X'00' WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::NegativeSortIndex => { source.execute( "UPDATE providers SET sort_index = -1 WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::MaxSortIndex => { source.execute( "UPDATE providers SET sort_index = 9223372036854775807 WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::BooleanDomain => { source.execute( "UPDATE providers SET in_failover_queue = 2 WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::Unsigned8Domain => { source.execute( "UPDATE proxy_config SET max_retries = 256 WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::Unsigned16Domain => { source.execute( "UPDATE proxy_config SET listen_port = 65536 WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::NonNegativeI32Domain => { source.execute( "UPDATE proxy_config SET streaming_first_byte_timeout = 2147483648 WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::Unsigned32Domain => { source.execute( "INSERT INTO proxy_request_logs ( request_id, provider_id, app_type, model, input_tokens, latency_ms, status_code, created_at ) VALUES ( 'domain-u32', 'remote-provider', 'pi', 'model', 4294967296, 1, 200, 1 )", [], )?; } InvalidRestoreCase::InputTokenSemanticsDomain => { source.execute( "INSERT INTO proxy_request_logs ( request_id, provider_id, app_type, model, input_token_semantics, latency_ms, status_code, created_at ) VALUES ( 'domain-token-semantics', 'remote-provider', 'pi', 'model', 3, 1, 200, 1 )", [], )?; } InvalidRestoreCase::NegativeCircuitThreshold => { source.execute( "UPDATE proxy_config SET circuit_error_rate_threshold = -0.5 WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::OutOfRangeCircuitThreshold => { source.execute( "UPDATE proxy_config SET circuit_error_rate_threshold = 6.5 WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::NonFiniteCircuitThreshold => { source.execute( "UPDATE proxy_config SET circuit_error_rate_threshold = ?1 WHERE app_type = 'claude'", [f64::INFINITY], )?; } InvalidRestoreCase::NegativeProviderMultiplier => { source.execute( "UPDATE providers SET cost_multiplier = '-1' WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::NegativeProviderMetaLimit => { source.execute( "UPDATE providers SET meta = '{\"limitDailyUsd\":\"-1\"}' WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::InvalidProviderPricingSource => { source.execute( "UPDATE providers SET meta = '{\"pricingModelSource\":\"invalid\"}' WHERE id = 'remote-provider'", [], )?; } InvalidRestoreCase::NegativeProxyMultiplier => { source.execute( "UPDATE proxy_config SET default_cost_multiplier = '-1' WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::InvalidProxyPricingSource => { source.execute( "UPDATE proxy_config SET pricing_model_source = 'invalid' WHERE app_type = 'claude'", [], )?; } InvalidRestoreCase::NegativeModelPrice => { source.execute( "INSERT INTO model_pricing ( model_id, display_name, input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million ) VALUES ( 'negative-price', 'Negative Price', '-1', '0', '0', '0' )", [], )?; } InvalidRestoreCase::DuplicateProvider => { source.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('remote-provider', 'pi', 'Duplicate', '{}', '{}')", [], )?; } InvalidRestoreCase::DuplicateEndpoint => { source.execute_batch( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) VALUES ('remote-provider', 'pi', 'https://duplicate.invalid', NULL, NULL), ('remote-provider', 'pi', 'https://duplicate.invalid', 1, 2);", )?; } InvalidRestoreCase::ForeignKeyOrphan => { source.execute_batch( "PRAGMA foreign_keys = OFF; INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) VALUES ('missing', 'pi', 'https://orphan.invalid', NULL, NULL); PRAGMA foreign_keys = ON;", )?; } InvalidRestoreCase::FutureVersion => { Database::set_user_version(&source, SCHEMA_VERSION + 1)?; } } Ok(source) } fn assert_weak_ledgers_are_rebuilt( entry_point: RestoreEntryPoint, filename: &str, ) -> Result<(), AppError> { let source = weak_ledger_source()?; let target = Database::memory()?; seed_local_ledgers(&target)?; run_restore_entry(&target, &source, entry_point, filename)?; let conn = crate::database::lock_conn!(target.conn); validate_stage_rows(&conn)?; validate_canonical_behaviors(&conn)?; let counts: (i64, i64, i64, i64, i64) = conn.query_row( "SELECT (SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'pi'), (SELECT COUNT(*) FROM pi_provider_projections WHERE provider_id = 'local-provider' AND provider_key = 'local-key'), (SELECT COUNT(*) FROM pi_provider_projections WHERE provider_id = 'remote-provider' OR provider_key = 'remote-key'), (SELECT COUNT(*) FROM skill_deployments WHERE skill_id = 'local-skill' AND destination_key = '/local'), (SELECT COUNT(*) FROM skill_deployments WHERE skill_id = 'remote-skill')", [], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) }, )?; assert_eq!(counts, (1, 1, 0, 1, 0)); Ok(()) } fn assert_invalid_live_copy_is_atomic( entry_point: RestoreEntryPoint, filename: &str, ) -> Result<(), AppError> { let source = canonical_restore_source()?; let target = Database::memory()?; { let conn = crate::database::lock_conn!(target.conn); conn.execute_batch( "PRAGMA ignore_check_constraints = ON; INSERT INTO skill_deployments ( app_type, skill_id, destination, destination_key, method, source_identity, created_at, updated_at ) VALUES ( 'codex', 'invalid-local', '/invalid', '/invalid', 'move', 'invalid-source', 1, 1 ); PRAGMA ignore_check_constraints = OFF;", )?; } let before = logical_snapshot(&target)?; assert!(run_restore_entry(&target, &source, entry_point, filename).is_err()); assert_eq!(logical_snapshot(&target)?, before); Ok(()) } fn assert_weak_endpoint_is_canonicalized( entry_point: RestoreEntryPoint, filename: &str, ) -> Result<(), AppError> { let source = weak_endpoint_source()?; let target = Database::memory()?; seed_local_ledgers(&target)?; run_restore_entry(&target, &source, entry_point, filename)?; let conn = crate::database::lock_conn!(target.conn); validate_stage_rows(&conn)?; validate_canonical_behaviors(&conn)?; let endpoint_count: i64 = conn.query_row( "SELECT COUNT(*) FROM provider_endpoints WHERE provider_id = 'remote-provider' AND app_type = 'pi' AND url = 'https://weak.test'", [], |row| row.get(0), )?; assert_eq!(endpoint_count, 1); Ok(()) } #[test] #[serial] fn sql_and_binary_restore_share_canonical_prepublication_contracts() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create canonical restore test home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); assert_weak_ledgers_are_rebuilt(RestoreEntryPoint::Sql, "unused-ledger.db")?; assert_invalid_live_copy_is_atomic(RestoreEntryPoint::Sql, "unused-invalid.db")?; assert_weak_endpoint_is_canonicalized(RestoreEntryPoint::Sql, "unused-endpoint.db")?; assert_weak_ledgers_are_rebuilt(RestoreEntryPoint::Binary, "ledger.db")?; assert_invalid_live_copy_is_atomic(RestoreEntryPoint::Binary, "invalid-copy.db")?; assert_weak_endpoint_is_canonicalized(RestoreEntryPoint::Binary, "weak-endpoint.db")?; Ok(()) } #[test] #[serial] fn sql_and_binary_restore_preserve_incremental_auto_vacuum() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create auto-vacuum restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let target = Database::init()?; let source = canonical_restore_source()?; let auto_vacuum = || -> Result { let conn = crate::database::lock_conn!(target.conn); conn.query_row("PRAGMA auto_vacuum", [], |row| row.get(0)) .map_err(|error| AppError::Database(error.to_string())) }; assert_eq!(auto_vacuum()?, 2); run_restore_entry( &target, &source, RestoreEntryPoint::Sql, "unused-auto-vacuum.db", )?; assert_eq!(auto_vacuum()?, 2); run_restore_entry( &target, &source, RestoreEntryPoint::Binary, "binary-auto-vacuum.db", )?; assert_eq!(auto_vacuum()?, 2); Ok(()) } #[test] #[serial] fn public_restore_entries_discard_hostile_schema_and_publish_only_canonical_objects( ) -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create hostile schema restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let source = rewritten_source( Some(HOSTILE_PROVIDERS_NOCASE_REPLACE), Some(WEAK_PROVIDER_ENDPOINTS), )?; source.execute_batch( "CREATE INDEX source_leak_index ON providers(name); CREATE VIEW source_leak_view AS SELECT id, app_type FROM providers; CREATE TRIGGER source_leak_trigger AFTER INSERT ON provider_endpoints BEGIN UPDATE settings SET value = NEW.url WHERE key = 'source-leak'; END; INSERT INTO provider_endpoints (id, provider_id, app_type, url, added_at, last_used) VALUES (7001, 'remote-provider', 'pi', 'https://weak.invalid', NULL, 8);", )?; for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let target = Database::memory()?; seed_local_ledgers(&target)?; run_restore_entry( &target, &source, entry_point, &format!("hostile-schema-{index}.db"), )?; let conn = crate::database::lock_conn!(target.conn); validate_stage_rows(&conn)?; validate_canonical_behaviors(&conn)?; let leaked_objects: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_schema WHERE name IN ( 'source_leak_index', 'source_leak_view', 'source_leak_trigger' )", [], |row| row.get(0), )?; assert_eq!(leaked_objects, 0, "source executable DDL must not publish"); let provider_schema: String = conn.query_row( "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'providers'", [], |row| row.get(0), )?; assert!(!provider_schema.to_ascii_uppercase().contains("NOCASE")); assert!(!provider_schema.to_ascii_uppercase().contains("REPLACE")); let endpoint_schema: String = conn.query_row( "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'provider_endpoints'", [], |row| row.get(0), )?; let endpoint_schema = endpoint_schema.to_ascii_uppercase(); assert!(endpoint_schema.contains("FOREIGN KEY")); assert!(endpoint_schema.contains("UNIQUE")); let endpoint: (i64, Option, Option) = conn.query_row( "SELECT id, added_at, last_used FROM provider_endpoints WHERE provider_id = 'remote-provider' AND app_type = 'pi' AND url = 'https://weak.invalid'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; assert_eq!(endpoint, (7001, None, Some(8))); } Ok(()) } #[test] #[serial] fn public_restore_entries_abort_invalid_rows_without_live_or_ledger_mutation( ) -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create invalid restore matrix home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); for case in InvalidRestoreCase::ALL { for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let source = invalid_restore_source(case)?; let target = Database::memory()?; seed_live_restore_state(&target)?; let before = logical_snapshot(&target)?; let result = run_restore_entry( &target, &source, entry_point, &format!("invalid-{}-{entry_index}.db", case.label()), ); assert!( result.is_err(), "{} via entry {entry_index} must fail closed", case.label() ); assert_eq!( logical_snapshot(&target)?, before, "{} via entry {entry_index} changed live state", case.label() ); } } Ok(()) } #[test] #[serial] fn public_restore_entries_preserve_nulls_unknown_json_and_explicit_ids() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create lossless restore matrix home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let source = canonical_restore_source()?; let settings = r#"{"api":"future-api","futureShape":{"nested":[1,2,3]}}"#; let meta = r#"{"futureMeta":{"opaque":true}}"#; source.execute( "UPDATE providers SET settings_config = ?1, meta = ?2 WHERE id = 'remote-provider' AND app_type = 'pi'", rusqlite::params![settings, meta], )?; source.execute( "INSERT INTO provider_endpoints (id, provider_id, app_type, url, added_at, last_used) VALUES (9001, 'remote-provider', 'pi', 'https://null.invalid', NULL, NULL)", [], )?; source.execute( "INSERT INTO provider_endpoints (id, provider_id, app_type, url, added_at, last_used) VALUES (-9001, 'remote-provider', 'pi', 'https://negative-id.invalid', NULL, NULL)", [], )?; source.execute( "INSERT INTO stream_check_logs ( id, provider_id, provider_name, app_type, status, success, message, tested_at ) VALUES ( -9002, 'remote-provider', 'Remote Provider', 'pi', 'ok', 1, 'ok', 1 )", [], )?; for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let target = Database::memory()?; run_restore_entry( &target, &source, entry_point, &format!("lossless-{index}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let restored: (i64, Option, Option, String, String) = conn.query_row( "SELECT e.id, e.added_at, e.last_used, p.settings_config, p.meta FROM provider_endpoints AS e JOIN providers AS p ON p.id = e.provider_id AND p.app_type = e.app_type WHERE e.url = 'https://null.invalid'", [], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) }, )?; assert_eq!( restored, (9001, None, None, settings.to_string(), meta.to_string()) ); let negative_endpoint_id: i64 = conn.query_row( "SELECT id FROM provider_endpoints WHERE url = 'https://negative-id.invalid'", [], |row| row.get(0), )?; let negative_stream_id: i64 = conn.query_row( "SELECT id FROM stream_check_logs WHERE provider_id = 'remote-provider'", [], |row| row.get(0), )?; assert_eq!(negative_endpoint_id, -9001); assert_eq!(negative_stream_id, -9002); conn.execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) VALUES ( 'remote-provider', 'pi', 'https://next-id.invalid', NULL, NULL )", [], )?; assert!( conn.last_insert_rowid() > 9001, "AUTOINCREMENT must advance without copying sqlite_sequence" ); } Ok(()) } #[test] #[serial] fn public_restore_entries_reject_v16_endpoint_repair_without_merging() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create v16 duplicate endpoint restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let source = actual_v16_duplicate_endpoint_source()?; let target = Database::memory()?; seed_live_restore_state(&target)?; let before = logical_snapshot(&target)?; let error = run_restore_entry( &target, &source, entry_point, &format!("duplicate-v16-{entry_index}.db"), ) .expect_err("an untrusted v16 duplicate endpoint must not be repaired"); assert!( matches!(error, AppError::InvalidInput(_)), "migration repair rejection must remain structured: {error:?}" ); assert!( error.to_string().contains("migration repair is forbidden"), "restore must fail at the untrusted migration boundary: {error}" ); assert_eq!( logical_snapshot(&target)?, before, "failed v16 repair changed live state via entry {entry_index}" ); let duplicate_count: i64 = source.query_row( "SELECT COUNT(*) FROM provider_endpoints WHERE provider_id = 'remote-provider' AND app_type = 'pi' AND url = 'https://duplicate-v16.invalid'", [], |row| row.get(0), )?; assert_eq!( duplicate_count, 2, "untrusted migration must not merge the source rows" ); } Ok(()) } #[test] #[serial] fn every_supported_user_version_has_a_public_migration_sentinel() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create restore migration matrix home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); // Exercise the oldest supported layout, rather than merely relabeling // a current schema. Missing columns must be supplied by the real // v0..current migration chain before fixed-column restore begins. for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let oldest = Connection::open_in_memory()?; oldest.execute_batch( "CREATE TABLE providers ( id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL DEFAULT '{}', website_url TEXT, PRIMARY KEY (id, app_type) ); INSERT INTO providers ( id, app_type, name, settings_config, website_url ) VALUES ( 'actual-v0-provider', 'pi', 'Actual v0 Provider', '{}', NULL ); PRAGMA user_version = 0;", )?; let target = Database::memory()?; run_restore_entry( &target, &oldest, entry_point, &format!("actual-v0-{entry_index}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let restored: i64 = conn.query_row( "SELECT COUNT(*) FROM providers WHERE id = 'actual-v0-provider' AND app_type = 'pi' AND cost_multiplier = '1.0'", [], |row| row.get(0), )?; assert_eq!(restored, 1, "actual oldest-layout migration sentinel"); } // Exercise the real immediately-previous v16 shape. In particular, // these columns and device-local tables did not exist yet; relabeling // a v17 database cannot prove that the migration supplies them. for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let previous = canonical_restore_source()?; previous.execute_batch( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) VALUES ('remote-provider', 'pi', 'https://v16.invalid', 16, NULL); INSERT INTO skills ( id, name, directory, enabled_codex, enabled_pi, installed_at, updated_at ) VALUES ('actual-v16-skill', 'Actual v16 Skill', '/v16', 1, 0, 16, 16); PRAGMA foreign_keys = OFF; DROP TABLE pi_provider_projections; DROP TABLE skill_deployments; ALTER TABLE provider_endpoints DROP COLUMN last_used; ALTER TABLE skills DROP COLUMN enabled_pi; PRAGMA user_version = 16;", )?; let target = Database::memory()?; run_restore_entry( &target, &previous, entry_point, &format!("actual-v16-{entry_index}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let sentinel: (Option, i64, i64, i64) = conn.query_row( "SELECT (SELECT last_used FROM provider_endpoints WHERE provider_id = 'remote-provider' AND app_type = 'pi' AND url = 'https://v16.invalid'), (SELECT enabled_pi FROM skills WHERE id = 'actual-v16-skill'), (SELECT COUNT(*) FROM pi_provider_projections), (SELECT COUNT(*) FROM skill_deployments)", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), )?; assert_eq!(sentinel, (None, 0, 0, 0)); } // Every version still gets a public dispatch sentinel. The separate // historical-layout cases above prevent this matrix from passing only // because current tables were stamped with an older user_version. for version in 0..=SCHEMA_VERSION { let source_db = Database::memory()?; { let conn = crate::database::lock_conn!(source_db.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES (?1, 'pi', ?2, '{}', '{}')", rusqlite::params![ format!("migration-v{version}"), format!("Migration v{version}") ], )?; Database::set_user_version(&conn, version)?; } let source = source_db.snapshot_to_memory()?; let target = Database::memory()?; run_restore_entry( &target, &source, RestoreEntryPoint::Sql, &format!("unused-migration-v{version}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let restored: i64 = conn.query_row( "SELECT COUNT(*) FROM providers WHERE id = ?1 AND app_type = 'pi'", [format!("migration-v{version}")], |row| row.get(0), )?; assert_eq!(restored, 1, "SQL migration sentinel v{version}"); } for version in [0, SCHEMA_VERSION - 1, SCHEMA_VERSION] { let source_db = Database::memory()?; { let conn = crate::database::lock_conn!(source_db.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES (?1, 'pi', ?2, '{}', '{}')", rusqlite::params![ format!("binary-migration-v{version}"), format!("Binary migration v{version}") ], )?; Database::set_user_version(&conn, version)?; } let source = source_db.snapshot_to_memory()?; let target = Database::memory()?; run_restore_entry( &target, &source, RestoreEntryPoint::Binary, &format!("migration-v{version}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let restored: i64 = conn.query_row( "SELECT COUNT(*) FROM providers WHERE id = ?1 AND app_type = 'pi'", [format!("binary-migration-v{version}")], |row| row.get(0), )?; assert_eq!(restored, 1, "binary migration sentinel v{version}"); } Ok(()) } #[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 之后、canonical validation 之前, // 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。 assert!( !target.exists(), "被拒绝的 {label} 不得在磁盘上留下文件: {}", target.display() ); let _ = std::fs::remove_file(&target); } Ok(()) } #[test] fn public_sql_restore_rejects_temp_schema_growth_atomically() -> Result<(), AppError> { let malicious = format!( "{}\n\ CREATE TEMP TABLE scratch_escape(payload BLOB);\n\ INSERT INTO scratch_escape(payload) VALUES (zeroblob(67108863));\n\ INSERT INTO scratch_escape(payload) SELECT payload FROM scratch_escape;", super::CC_SWITCH_SQL_EXPORT_HEADER ); for sync in [false, true] { let database = Database::memory()?; seed_live_restore_state(&database)?; let before = logical_snapshot(&database)?; let result = if sync { database.import_sql_string_for_sync(&malicious) } else { database.import_sql_string(&malicious) }; assert!( result.is_err(), "TEMP schema DDL must be rejected by the public {} SQL entry", if sync { "sync" } else { "user" } ); assert_eq!( logical_snapshot(&database)?, before, "rejected TEMP growth changed live state" ); } Ok(()) } #[test] #[serial] fn public_file_restore_entries_reject_symlink_directory_and_fifo() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create restore file-shape home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::memory()?; let backup_dir = crate::config::get_app_config_dir().join("backups"); for filename in ["C:outside.db", "backup.db:stream.db", "../outside.db"] { assert!( database.restore_from_backup(filename).is_err(), "restore filename must be exactly one portable normal component" ); } #[cfg(unix)] { use std::os::unix::fs::symlink; let external = test_home.path().join("external-backups"); fs::create_dir_all(&external).map_err(|error| AppError::io(&external, error))?; fs::create_dir_all( backup_dir .parent() .ok_or_else(|| AppError::InvalidInput("backup parent missing".to_string()))?, ) .map_err(|error| AppError::io(&backup_dir, error))?; fs::write(external.join("outside.db"), b"outside") .map_err(|error| AppError::io(&external, error))?; symlink(&external, &backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; assert!( database.restore_from_backup("outside.db").is_err(), "a symlinked backup directory must not authorize an outside source" ); fs::remove_file(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; } fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; let sql_directory = test_home.path().join("sql-directory"); fs::create_dir(&sql_directory).map_err(|error| AppError::io(&sql_directory, error))?; assert!(database.import_sql(&sql_directory).is_err()); let binary_directory = backup_dir.join("binary-directory.db"); fs::create_dir(&binary_directory) .map_err(|error| AppError::io(&binary_directory, error))?; assert!(database.restore_from_backup("binary-directory.db").is_err()); #[cfg(unix)] { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::symlink; let regular = test_home.path().join("regular-source"); fs::write(®ular, super::CC_SWITCH_SQL_EXPORT_HEADER) .map_err(|error| AppError::io(®ular, error))?; let sql_symlink = test_home.path().join("symlink.sql"); symlink(®ular, &sql_symlink).map_err(|error| AppError::io(&sql_symlink, error))?; assert!(database.import_sql(&sql_symlink).is_err()); let binary_symlink = backup_dir.join("symlink.db"); symlink(®ular, &binary_symlink) .map_err(|error| AppError::io(&binary_symlink, error))?; assert!(database.restore_from_backup("symlink.db").is_err()); for fifo in [ test_home.path().join("source-fifo.sql"), backup_dir.join("source-fifo.db"), ] { let path = CString::new(fifo.as_os_str().as_bytes()) .map_err(|error| AppError::InvalidInput(error.to_string()))?; let result = unsafe { libc::mkfifo(path.as_ptr(), 0o600) }; if result != 0 { return Err(AppError::io(&fifo, std::io::Error::last_os_error())); } } assert!(database .import_sql(&test_home.path().join("source-fifo.sql")) .is_err()); assert!(database.restore_from_backup("source-fifo.db").is_err()); } Ok(()) } #[cfg(any(unix, windows))] #[test] fn restore_file_identity_is_not_a_size_and_timestamp_surrogate() -> Result<(), AppError> { use std::fs::FileTimes; use std::time::{Duration, SystemTime}; let directory = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create restore identity test directory".to_string(), source: error, })?; let first_path = directory.path().join("first.db"); let second_path = directory.path().join("second.db"); fs::write(&first_path, b"same-size").map_err(|error| AppError::io(&first_path, error))?; fs::write(&second_path, b"different").map_err(|error| AppError::io(&second_path, error))?; let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); let times = FileTimes::new().set_modified(timestamp); let first = super::open_nofollow(&first_path).map_err(|error| AppError::io(&first_path, error))?; let second = super::open_nofollow(&second_path) .map_err(|error| AppError::io(&second_path, error))?; first .set_times(times) .map_err(|error| AppError::io(&first_path, error))?; second .set_times(times) .map_err(|error| AppError::io(&second_path, error))?; let first_metadata = first .metadata() .map_err(|error| AppError::io(&first_path, error))?; let second_metadata = second .metadata() .map_err(|error| AppError::io(&second_path, error))?; assert_eq!(first_metadata.len(), second_metadata.len()); assert_eq!( first_metadata.modified().ok(), second_metadata.modified().ok() ); assert!( !super::same_open_file_identity(&first, &second) .map_err(|error| AppError::io(&first_path, error))?, "stable file IDs must distinguish equal-size/equal-mtime files" ); Ok(()) } #[test] #[serial] fn repeated_live_database_init_is_rejected_until_primary_drops() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create single-writer ownership home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let primary = Database::init()?; let live_path = crate::config::get_app_config_dir().join("cc-switch.db"); assert_eq!( Database::stored_user_version_exceeds_supported(&live_path)?, None, "the read-only version probe must coexist with the primary writer" ); let duplicate = match Database::init() { Ok(_) => { return Err(AppError::Message( "a second writable live Database unexpectedly initialized".to_string(), )); } Err(error) => error, }; assert!( matches!(duplicate, AppError::Conflict(_)), "duplicate live init must be a structured conflict, got {duplicate:?}" ); drop(primary); let reopened = Database::init()?; drop(reopened); Ok(()) } #[test] #[serial] fn restore_safety_backup_and_publish_hold_one_live_write_boundary() -> Result<(), AppError> { use crate::database::NewProviderAggregate; use crate::provider::ProviderMutationInput; use serde_json::json; use std::sync::{mpsc, Arc, TryLockError}; use std::time::Duration; fn input(id: &str, name: &str) -> ProviderMutationInput { ProviderMutationInput { id: id.to_string(), name: name.to_string(), settings_config: json!({"env": {}}), website_url: None, category: None, created_at: Some(1_700_000_000), sort_index: None, notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create restore write-boundary home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::init()?; database.create_provider(NewProviderAggregate::from_input( "pi", input("before-restore", "Before Restore"), )?)?; let backup_dir = crate::config::get_app_config_dir().join("backups"); fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; let source_path = backup_dir.join("write-boundary-source.db"); let source = canonical_restore_source()?; let mut destination = Connection::open(&source_path)?; { let backup = Backup::new(&source, &mut destination)?; super::run_backup_to_completion(&backup, "prepare write-boundary fixture")?; } drop(destination); let database = Arc::new(database); let restore_database = Arc::clone(&database); let (safety_ready_tx, safety_ready_rx) = mpsc::channel(); let (release_restore_tx, release_restore_rx) = mpsc::channel(); let restore = std::thread::spawn(move || { super::TEST_AFTER_SAFETY_BACKUP.with(|slot| { *slot.borrow_mut() = Some(Box::new(move || { safety_ready_tx.send(()).expect("signal safety-backup seam"); release_restore_rx .recv() .expect("release restore after seam assertion"); })); }); restore_database .restore_from_backup("write-boundary-source.db") .map_err(|error| error.to_string()) }); safety_ready_rx .recv_timeout(Duration::from_secs(5)) .expect("restore reached the post-safety-backup seam"); assert!(matches!( database.conn.try_lock(), Err(TryLockError::WouldBlock) )); let writer_database = Arc::clone(&database); let (writer_started_tx, writer_started_rx) = mpsc::channel(); let writer = std::thread::spawn(move || -> Result<(), String> { writer_started_tx .send(()) .map_err(|error| error.to_string())?; let aggregate = NewProviderAggregate::from_input("pi", input("late-writer", "Late Writer")) .map_err(|error| error.to_string())?; writer_database .create_provider(aggregate) .map_err(|error| error.to_string()) }); writer_started_rx .recv_timeout(Duration::from_secs(5)) .expect("concurrent writer started"); release_restore_tx .send(()) .expect("release restore publication"); let safety_id = restore .join() .expect("restore thread did not panic") .map_err(AppError::Message)?; writer .join() .expect("writer thread did not panic") .map_err(AppError::Message)?; assert!(!safety_id.is_empty()); let live_counts: (i64, i64, i64) = { let conn = crate::database::lock_conn!(database.conn); conn.query_row( "SELECT (SELECT COUNT(*) FROM providers WHERE id = 'before-restore' AND app_type = 'pi'), (SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'pi'), (SELECT COUNT(*) FROM providers WHERE id = 'late-writer' AND app_type = 'pi')", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )? }; assert_eq!(live_counts, (0, 1, 1)); let safety = Connection::open(backup_dir.join(format!("{safety_id}.db")))?; let safety_counts: (i64, i64, i64) = safety.query_row( "SELECT (SELECT COUNT(*) FROM providers WHERE id = 'before-restore' AND app_type = 'pi'), (SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'pi'), (SELECT COUNT(*) FROM providers WHERE id = 'late-writer' AND app_type = 'pi')", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; assert_eq!(safety_counts, (1, 0, 0)); Ok(()) } #[test] #[serial] fn retention_never_removes_the_just_completed_safety_backup() -> Result<(), AppError> { use std::fs::FileTimes; use std::time::{Duration, SystemTime}; let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create safety-retention home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let mut settings = get_settings(); settings.backup_retain_count = Some(1); let _settings_guard = AppSettingsGuard::replace(settings)?; let database = Database::init()?; { let conn = crate::database::lock_conn!(database.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('live-before-retention', 'pi', 'Live', '{}', '{}')", [], )?; } let backup_dir = crate::config::get_app_config_dir().join("backups"); fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; let source_path = backup_dir.join("future-source.db"); let source = canonical_restore_source()?; let mut destination = Connection::open(&source_path)?; { let backup = Backup::new(&source, &mut destination)?; super::run_backup_to_completion(&backup, "prepare future-mtime restore source")?; } drop(destination); File::open(&source_path) .and_then(|file| { file.set_times( FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)), ) }) .map_err(|error| AppError::io(&source_path, error))?; let safety_id = database.restore_from_backup("future-source.db")?; let safety_path = backup_dir.join(format!("{safety_id}.db")); assert!( safety_path.is_file(), "retention=1 must retain the safety backup even when the source mtime is newer" ); let safety = Connection::open(&safety_path)?; let old_live: i64 = safety.query_row( "SELECT COUNT(*) FROM providers WHERE id = 'live-before-retention' AND app_type = 'pi'", [], |row| row.get(0), )?; assert_eq!(old_live, 1); let equal_dir = test_home.path().join("equal-mtime-retention"); fs::create_dir(&equal_dir).map_err(|error| AppError::io(&equal_dir, error))?; let protected = equal_dir.join("protected.db"); let peer = equal_dir.join("peer.db"); fs::write(&protected, b"protected").map_err(|error| AppError::io(&protected, error))?; fs::write(&peer, b"peer").map_err(|error| AppError::io(&peer, error))?; let equal_time = FileTimes::new().set_modified(SystemTime::UNIX_EPOCH); File::open(&protected) .and_then(|file| file.set_times(equal_time)) .map_err(|error| AppError::io(&protected, error))?; File::open(&peer) .and_then(|file| file.set_times(equal_time)) .map_err(|error| AppError::io(&peer, error))?; Database::cleanup_db_backups(&equal_dir, Some(&protected))?; assert!( protected.is_file(), "equal mtime must not defeat protection" ); assert!(!peer.exists()); Ok(()) } #[test] #[serial] fn incomplete_safety_backup_is_rejected_and_removed() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create safety-backup contention home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::init()?; { let conn = crate::database::lock_conn!(database.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('live-before-busy-backup', 'pi', 'Live', '{}', '{}')", [], )?; } let live_path = crate::config::get_app_config_dir().join("cc-switch.db"); let external = Connection::open(&live_path)?; external.execute_batch( "BEGIN EXCLUSIVE; INSERT INTO settings (key, value) VALUES ('uncommitted-lock', 'held');", )?; let _retry_guard = BackupRetryGuard::set(0); let result = database.backup_database_file(); external.execute_batch("ROLLBACK;")?; assert!( result.is_err(), "a transient SQLite backup result must not be reported as success" ); let backup_dir = crate::config::get_app_config_dir().join("backups"); let leftovers = fs::read_dir(&backup_dir) .map(|entries| entries.filter_map(Result::ok).count()) .unwrap_or(0); assert_eq!(leftovers, 0, "incomplete safety artifacts must be removed"); Ok(()) } #[test] #[serial] fn incomplete_publish_is_rejected_and_live_database_is_unchanged() -> Result<(), AppError> { use std::sync::mpsc; let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create publish contention home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::init()?; { let conn = crate::database::lock_conn!(database.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('live-before-busy-publish', 'pi', 'Live', '{}', '{}')", [], )?; } let before = logical_snapshot(&database)?; let source = canonical_restore_source()?; let sql = Database::dump_sql(&source, &[])?; let live_path = crate::config::get_app_config_dir().join("cc-switch.db"); let (start_tx, start_rx) = mpsc::channel(); let (locked_tx, locked_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); let locker = std::thread::spawn(move || -> Result<(), String> { start_rx.recv().map_err(|error| error.to_string())?; let external = Connection::open(live_path).map_err(|error| error.to_string())?; external .execute_batch("BEGIN EXCLUSIVE;") .map_err(|error| error.to_string())?; locked_tx.send(()).map_err(|error| error.to_string())?; release_rx.recv().map_err(|error| error.to_string())?; external .execute_batch("ROLLBACK;") .map_err(|error| error.to_string()) }); super::TEST_AFTER_SAFETY_BACKUP.with(|slot| { *slot.borrow_mut() = Some(Box::new(move || { start_tx.send(()).expect("start external publish lock"); locked_rx.recv().expect("external publish lock acquired"); })); }); let _retry_guard = BackupRetryGuard::set(0); let result = database.import_sql_string(&sql); release_tx .send(()) .expect("release external publish lock after restore result"); locker .join() .expect("publish-lock thread did not panic") .map_err(AppError::Message)?; assert!( result.is_err(), "a non-Done publication must not be reported as success" ); assert_eq!( logical_snapshot(&database)?, before, "failed publication changed the live database" ); let backup_dir = crate::config::get_app_config_dir().join("backups"); let safety_paths = fs::read_dir(&backup_dir) .map_err(|error| AppError::io(&backup_dir, error))? .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().is_some_and(|extension| extension == "db")) .collect::>(); assert!( !safety_paths.is_empty(), "completed safety backup remains available" ); let complete_safety_exists = safety_paths.iter().any(|path| { Connection::open(path) .and_then(|safety| { safety.query_row( "SELECT COUNT(*) FROM providers WHERE id = 'live-before-busy-publish' AND app_type = 'pi'", [], |row| row.get::<_, i64>(0), ) }) .is_ok_and(|count| count == 1) }); assert!(complete_safety_exists, "safety backup must be complete"); Ok(()) } #[test] #[serial] fn restore_file_size_limits_accept_n_and_publicly_reject_n_plus_one() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create restore size-boundary home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::memory()?; let backup_dir = crate::config::get_app_config_dir().join("backups"); fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; let sql_n = test_home.path().join("sql-n"); File::create(&sql_n) .and_then(|file| file.set_len(MAX_SQL_IMPORT_BYTES)) .map_err(|error| AppError::io(&sql_n, error))?; assert_eq!( validate_regular_file(&sql_n, MAX_SQL_IMPORT_BYTES)?.len(), MAX_SQL_IMPORT_BYTES ); let sql_n_plus_one = test_home.path().join("sql-n-plus-one"); File::create(&sql_n_plus_one) .and_then(|file| file.set_len(MAX_SQL_IMPORT_BYTES + 1)) .map_err(|error| AppError::io(&sql_n_plus_one, error))?; assert!(database.import_sql(&sql_n_plus_one).is_err()); let binary_n = backup_dir.join("binary-n.db"); File::create(&binary_n) .and_then(|file| file.set_len(MAX_BINARY_RESTORE_BYTES)) .map_err(|error| AppError::io(&binary_n, error))?; assert_eq!( validate_regular_file(&binary_n, MAX_BINARY_RESTORE_BYTES)?.len(), MAX_BINARY_RESTORE_BYTES ); let binary_n_plus_one = backup_dir.join("binary-n-plus-one.db"); File::create(&binary_n_plus_one) .and_then(|file| file.set_len(MAX_BINARY_RESTORE_BYTES + 1)) .map_err(|error| AppError::io(&binary_n_plus_one, error))?; assert!(database .restore_from_backup("binary-n-plus-one.db") .is_err()); Ok(()) } #[test] #[serial] fn public_restore_entries_enforce_vm_and_page_budgets() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create restore budget home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::memory()?; { let _limit_guard = RestoreLimitGuard::set(Some(1_000), None); let expensive = format!( "{}\n\ WITH RECURSIVE counter(value) AS (\n\ VALUES(0)\n\ UNION ALL SELECT value + 1 FROM counter WHERE value < 100000\n\ ) SELECT SUM(value) FROM counter;", super::CC_SWITCH_SQL_EXPORT_HEADER ); let error = database .import_sql_string(&expensive) .expect_err("VM budget must interrupt untrusted SQL"); assert!( error.to_string().to_ascii_lowercase().contains("interrupt"), "unexpected VM budget error: {error}" ); } { let _limit_guard = RestoreLimitGuard::set(None, Some(8)); let page_heavy = format!( "{}\n\ CREATE TABLE filler (payload BLOB);\n\ INSERT INTO filler(payload) VALUES (zeroblob(1048576));", super::CC_SWITCH_SQL_EXPORT_HEADER ); assert!( database.import_sql_string(&page_heavy).is_err(), "SQL page budget must reject oversized scratch growth" ); } let backup_dir = crate::config::get_app_config_dir().join("backups"); fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; let binary_path = backup_dir.join("page-budget.db"); let source = canonical_restore_source()?; let mut destination = Connection::open(&binary_path)?; { let backup = Backup::new(&source, &mut destination)?; super::run_backup_to_completion(&backup, "prepare page-budget fixture")?; } drop(destination); { let _limit_guard = RestoreLimitGuard::set(None, Some(8)); assert!( database.restore_from_backup("page-budget.db").is_err(), "binary page budget must reject oversized scratch growth" ); } let large_page_path = backup_dir.join("page-size-64k.db"); let large_page_source = Connection::open(&large_page_path)?; large_page_source.execute_batch("PRAGMA page_size = 65536; VACUUM;")?; Database::create_tables_on_conn(&large_page_source, MigrationRunContext::LocalUpgrade)?; Database::apply_schema_migrations_on_conn( &large_page_source, MigrationRunContext::LocalUpgrade, )?; large_page_source.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('large-page-source', 'pi', 'Large Page', '{}', '{}')", [], )?; let source_page_size: u64 = large_page_source.query_row("PRAGMA page_size", [], |row| row.get(0))?; assert_eq!(source_page_size, 65_536); drop(large_page_source); let scratch = UntrustedScratch::from_binary(&large_page_path)?; let scratch_page_size: u64 = scratch .connection .query_row("PRAGMA page_size", [], |row| row.get(0))?; let scratch_page_limit: u64 = scratch .connection .query_row("PRAGMA max_page_count", [], |row| row.get(0))?; assert_eq!(scratch_page_size, 65_536); assert!( scratch_page_size.saturating_mul(scratch_page_limit) <= MAX_SCRATCH_BYTES, "binary cloning must rebind the page-count limit to the adopted page size" ); database.restore_from_backup("page-size-64k.db")?; 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] #[serial] fn empty_canonical_backups_restore_through_sql_and_binary_entries() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create empty restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let empty = Database::memory()?.snapshot_to_memory()?; for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let target = Database::memory()?; { let conn = crate::database::lock_conn!(target.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('to-be-cleared', 'pi', 'Old', '{}', '{}')", [], )?; } run_restore_entry( &target, &empty, entry_point, &format!("empty-canonical-{index}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let counts: (i64, i64) = conn.query_row( "SELECT (SELECT COUNT(*) FROM providers), (SELECT COUNT(*) FROM mcp_servers)", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; assert_eq!(counts, (0, 0)); } Ok(()) } #[test] #[serial] fn canonical_behavior_probe_never_claims_legal_provider_keys() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create behavior-probe restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let source = Database::memory()?; { let conn = crate::database::lock_conn!(source.conn); conn.execute_batch( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('__restore_Aa', '__probe', 'Upper', '{}', '{}'), ('__restore_aa', '__probe', 'Lower', '{}', '{}');", )?; } let source = source.snapshot_to_memory()?; for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let target = Database::memory()?; run_restore_entry( &target, &source, entry_point, &format!("legal-probe-keys-{index}.db"), )?; let conn = crate::database::lock_conn!(target.conn); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM providers WHERE app_type = '__probe' AND id IN ('__restore_Aa', '__restore_aa')", [], |row| row.get(0), )?; assert_eq!(count, 2); } Ok(()) } #[test] #[serial] fn gateway_credentials_are_never_portable_and_live_values_survive_both_restore_entries( ) -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create device-credential restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); let remote = Database::memory()?; { let conn = crate::database::lock_conn!(remote.conn); conn.execute_batch( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('remote', 'pi', 'Remote', '{}', '{}'); INSERT INTO settings (key, value) VALUES ('portable-setting', 'remote-portable'), ('claude_desktop_gateway_token', 'remote-claude-secret'), ('pi_gateway_token', 'remote-pi-secret'); INSERT INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) VALUES ('/device/session.jsonl', 900, 900, 900);", )?; } for exported in [ remote.export_sql_string()?, remote.export_sql_string_for_sync()?, ] { for forbidden in [ "claude_desktop_gateway_token", "pi_gateway_token", "remote-claude-secret", "remote-pi-secret", "/device/session.jsonl", ] { assert!( !exported.contains(forbidden), "portable export leaked device credential material" ); } assert!(exported.contains("remote-portable")); } let remote_snapshot = remote.snapshot_to_memory()?; for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() { let target = Database::memory()?; { let conn = crate::database::lock_conn!(target.conn); conn.execute_batch( "INSERT INTO settings (key, value) VALUES ('claude_desktop_gateway_token', 'local-claude-secret'), ('pi_gateway_token', 'local-pi-secret'); INSERT INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) VALUES ('/device/session.jsonl', 10, 20, 30);", )?; } match entry_point { RestoreEntryPoint::Sql => { let mut sql = Database::dump_sql(&remote_snapshot, &[])?; sql = sql.replacen( "COMMIT;", "INSERT INTO settings (key, value) VALUES ('claude_desktop_gateway_token', 'remote-claude-secret'); INSERT INTO settings (key, value) VALUES ('pi_gateway_token', 'remote-pi-secret'); COMMIT;", 1, ); target.import_sql_string(&sql)?; } RestoreEntryPoint::Binary => { run_restore_entry( &target, &remote_snapshot, RestoreEntryPoint::Binary, &format!("device-credentials-{index}.db"), )?; } } let conn = crate::database::lock_conn!(target.conn); let values: (String, String, String) = conn.query_row( "SELECT (SELECT value FROM settings WHERE key = 'claude_desktop_gateway_token'), (SELECT value FROM settings WHERE key = 'pi_gateway_token'), (SELECT value FROM settings WHERE key = 'portable-setting')", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; assert_eq!( values, ( "local-claude-secret".to_string(), "local-pi-secret".to_string(), "remote-portable".to_string() ) ); let cursor: (i64, i64, i64) = conn.query_row( "SELECT last_modified, last_line_offset, last_synced_at FROM session_log_sync WHERE file_path = '/device/session.jsonl'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; assert_eq!(cursor, (10, 20, 30)); } Ok(()) } #[test] fn portable_export_and_import_preserve_device_local_pi_ledgers() -> Result<(), AppError> { 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 ('remote', 'pi', 'Remote', '{}', '{}')", [], )?; conn.execute( "INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('remote', 'remote-key', 1, 1)", [], )?; conn.execute( "INSERT INTO skill_deployments ( app_type, skill_id, destination, destination_key, method, source_identity, created_at, updated_at ) VALUES ('pi', 'remote-skill', '/remote', '/remote', 'copy', 'remote-source', 1, 1)", [], )?; } let exported = source.export_sql_string()?; assert!(!exported.contains("INSERT INTO \"pi_provider_projections\"")); assert!(!exported.contains("INSERT INTO \"skill_deployments\"")); let target = Database::memory()?; { let conn = crate::database::lock_conn!(target.conn); conn.execute( "INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('local', 'local-key', 2, 2)", [], )?; conn.execute( "INSERT INTO skill_deployments ( app_type, skill_id, destination, destination_key, method, source_identity, created_at, updated_at ) VALUES ('pi', 'local-skill', '/local', '/local', 'symlink', 'local-source', 2, 2)", [], )?; } target.import_sql_string(&exported)?; let conn = crate::database::lock_conn!(target.conn); let counts: (i64, i64, i64) = conn.query_row( "SELECT (SELECT COUNT(*) FROM providers WHERE id = 'remote' AND app_type = 'pi'), (SELECT COUNT(*) FROM pi_provider_projections WHERE provider_id = 'local' AND provider_key = 'local-key'), (SELECT COUNT(*) FROM skill_deployments WHERE skill_id = 'local-skill' AND destination_key = '/local')", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; assert_eq!(counts, (1, 1, 1)); Ok(()) } #[test] fn publish_copies_device_local_ledgers_at_the_commit_boundary() -> Result<(), AppError> { let remote = Database::memory()?; { let conn = crate::database::lock_conn!(remote.conn); conn.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('remote', 'pi', 'Remote', '{}', '{}')", [], )?; } let exported = remote.export_sql_string()?; let scratch = UntrustedScratch::from_sql(&exported)?; let stage = Database::build_canonical_stage(&scratch)?; let target = Database::memory()?; { let conn = crate::database::lock_conn!(target.conn); conn.execute( "INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('created-after-staging', 'local-key', 2, 2)", [], )?; } target.publish_canonical_stage(stage, RestoreFlavor::UserRestore)?; let conn = crate::database::lock_conn!(target.conn); let counts: (i64, i64) = conn.query_row( "SELECT (SELECT COUNT(*) FROM providers WHERE id = 'remote' AND app_type = 'pi'), (SELECT COUNT(*) FROM pi_provider_projections WHERE provider_id = 'created-after-staging' AND provider_key = 'local-key')", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; assert_eq!(counts, (1, 1)); Ok(()) } #[test] fn public_sql_restore_discards_input_trigger_before_local_copy() -> Result<(), AppError> { let staged_db = Database::memory()?; { let conn = crate::database::lock_conn!(staged_db.conn); conn.execute_batch( "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES ('remote', 'pi', 'Remote', '{}', '{}'); CREATE TRIGGER leak_local_projection AFTER INSERT ON pi_provider_projections BEGIN INSERT OR REPLACE INTO settings (key, value) VALUES ('leaked-provider-key', NEW.provider_key); END;", )?; } let exported = staged_db.export_sql_string()?; let target = Database::memory()?; { let conn = crate::database::lock_conn!(target.conn); conn.execute( "INSERT INTO pi_provider_projections (provider_id, provider_key, created_at, updated_at) VALUES ('local', 'local-secret-key', 1, 1)", [], )?; } target.import_sql_string(&exported)?; let conn = crate::database::lock_conn!(target.conn); let local_rows: i64 = conn.query_row( "SELECT COUNT(*) FROM pi_provider_projections WHERE provider_id = 'local' AND provider_key = 'local-secret-key'", [], |row| row.get(0), )?; let leaked_rows: i64 = conn.query_row( "SELECT COUNT(*) FROM settings WHERE key = 'leaked-provider-key'", [], |row| row.get(0), )?; let trigger_rows: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'trigger' AND name = 'leak_local_projection'", [], |row| row.get(0), )?; assert_eq!(local_rows, 1); assert_eq!(leaked_rows, 0); assert_eq!(trigger_rows, 0); 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', '{}', '{}')", [], )?; conn.execute( "INSERT INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) VALUES ('/same/device/session.jsonl', 900, 900, 900)", [], )?; } let portable_sql = remote_db.export_sql_string()?; let remote_sql = remote_db.export_sql_string_for_sync()?; for exported in [&portable_sql, &remote_sql] { assert!( !exported.contains("/same/device/session.jsonl"), "device-local session cursor must not be portable" ); } 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)", [], )?; conn.execute( "INSERT INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) VALUES ('/same/device/session.jsonl', 10, 20, 30)", [], )?; } 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, cursor): (i64, i64, i64, (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) })?; let cursor = conn.query_row( "SELECT last_modified, last_line_offset, last_synced_at FROM session_log_sync WHERE file_path = '/same/device/session.jsonl'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; (request_logs, rollups, stream_logs, cursor) }; 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" ); assert_eq!(cursor, (10, 20, 30), "local session cursor must win"); Ok(()) } #[test] #[serial] fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> { let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create periodic-maintenance test home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(test_home.path()); 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"); Ok(()) } }