Files
CC-Switch/src-tauri/src/database/backup.rs
T

5454 lines
208 KiB
Rust

//! 数据库备份和恢复
//!
//! 提供 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<Option<u64>> =
const { std::cell::Cell::new(None) };
static TEST_MAX_PAGE_COUNT: std::cell::Cell<Option<u64>> =
const { std::cell::Cell::new(None) };
static TEST_MAX_BACKUP_TRANSIENT_RETRIES: std::cell::Cell<Option<u32>> =
const { std::cell::Cell::new(None) };
static TEST_AFTER_SAFETY_BACKUP:
std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
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<AtomicBool>,
}
#[cfg(unix)]
fn open_nofollow(path: &Path) -> std::io::Result<File> {
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<File> {
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<File> {
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<bool> {
let opened = opened.metadata()?;
let current = current.metadata()?;
Ok(same_file_identity(&opened, &current))
}
#[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::<FILE_ID_INFO>() 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<bool> {
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<bool> {
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<File> {
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<File> {
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<File> {
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<File> {
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<File> {
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::<Vec<_>>();
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::<u16>())
.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::<OBJECT_ATTRIBUTES>() 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<File> {
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, &current_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<Metadata, AppError> {
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<File, AppError> {
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<Vec<u8>, 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<NamedTempFile, AppError> {
// 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, &current).map_err(|error| AppError::io(path, error))?;
let same_directory = same_open_file_identity(&directory, &current_directory)
.map_err(|error| AppError::io(directory_path, error))?;
if !same_source
|| !same_directory
|| metadata_is_reparse_point(&completed)
|| metadata_is_reparse_point(&current_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<Self, AppError> {
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<Self, AppError> {
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::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
);
result.map_err(|error| {
AppError::InvalidInput(format!("execute untrusted SQL import: {error}"))
})?;
scratch.finish_input()
}
fn from_binary(path: &Path) -> Result<Self, AppError> {
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, AppError> {
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}"
)));
}
// Gate obsolete backups before schema inspection, sanitizing DDL, or
// migration dispatch. LocalUpgrade never enters this scratch path and
// retains the complete historical in-place migration chain.
super::migration_source::require_supported_untrusted_restore_version(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()))?;
self.connection
.execute_batch("PRAGMA foreign_keys = OFF;")
.map_err(|error| AppError::Database(error.to_string()))?;
// Source recognition is deliberately first. In particular, never
// create current tables in this connection: doing so would turn a
// missing source table into an apparently valid empty table.
Database::validate_untrusted_migration_source(&self.connection)?;
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::<Result<Vec<_>, _>>()
.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::<Vec<_>>()
.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::<serde_json::Value>(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::<rust_decimal::Decimal>().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,
)
.map_err(|error| {
AppError::InvalidInput(format!(
"restore provider row '{app_type}/{id}' is not decodable: {error}"
))
})?;
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::<Vec<String>>(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::<crate::services::profile::ProfilePayload>(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::<Vec<_>>()
.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::<Result<Vec<_>, _>>()
.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<String>>(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<std::collections::BTreeSet<String>, AppError> {
let mut statement = conn
.prepare(
"SELECT name FROM sqlite_schema
WHERE type = 'table'
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::<Result<Vec<_>, _>>()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(tables
.into_iter()
.filter(|name| !super::is_sqlite_internal_table_name(name))
.collect())
}
fn assert_restore_policy_coverage(
canonical_tables: &std::collections::BTreeSet<String>,
policy_tables: &std::collections::BTreeSet<String>,
) -> Result<(), AppError> {
if canonical_tables == policy_tables {
Ok(())
} else {
let missing_policy = canonical_tables
.difference(policy_tables)
.cloned()
.collect::<Vec<_>>();
let stale_policy = policy_tables
.difference(canonical_tables)
.cloned()
.collect::<Vec<_>>();
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::<Result<Vec<_>, _>>()
.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::<std::collections::BTreeSet<_>>();
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<String, AppError> {
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<String, AppError> {
let snapshot = self.snapshot_to_memory()?;
Self::dump_sql(&snapshot, SYNC_SKIP_TABLES)
}
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
let dump = self.export_sql_string()?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
crate::config::atomic_write(target_path, dump.as_bytes())
}
/// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
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<String, AppError> {
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<String, AppError> {
self.import_sql_string_inner(sql_raw, RestoreFlavor::Sync)
}
fn import_sql_string_inner(
&self,
sql_raw: &str,
flavor: RestoreFlavor,
) -> Result<String, AppError> {
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<Connection, AppError> {
let conn = lock_conn!(self.conn);
let mut snapshot =
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
{
let backup =
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
run_backup_to_completion(&backup, "snapshot live database into memory")?;
}
Ok(snapshot)
}
fn build_canonical_stage(scratch: &UntrustedScratch) -> Result<CanonicalStage, AppError> {
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<Option<PathBuf>, 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<Option<PathBuf>, 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<Option<PathBuf>, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
if !db_path.exists() {
return Ok(None);
}
let backup_dir = db_path
.parent()
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
.join("backups");
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
let 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::<Vec<_>>(),
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::<Vec<_>>();
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<String, AppError> {
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<String>>(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()))?;
// Skip only the exact internal objects owned by this SQLite build.
// Prefix matching would misclassify names such as `sqliteX`.
if super::is_sqlite_internal_table_name(&name) {
continue;
}
output.push_str(&sql);
output.push_str(";\n");
if obj_type == "table" && !super::is_sqlite_internal_table_name(&name) {
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::<Vec<_>>()
.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)
}
#[cfg(test)]
pub(crate) fn dump_sql_for_migration_test(conn: &Connection) -> Result<String, AppError> {
Self::dump_sql(conn, &[])
}
/// 获取表的列名列表
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
.map_err(|e| AppError::Database(e.to_string()))?;
let iter = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut columns = Vec::new();
for col in iter {
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(columns)
}
/// 格式化 SQL 值
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
match value {
ValueRef::Null => Ok("NULL".to_string()),
ValueRef::Integer(i) => Ok(i.to_string()),
ValueRef::Real(f) => Ok(f.to_string()),
ValueRef::Text(t) => {
let text = std::str::from_utf8(t)
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
let escaped = text.replace('\'', "''");
Ok(format!("'{escaped}'"))
}
ValueRef::Blob(bytes) => {
let mut s = String::from("X'");
for b in bytes {
use std::fmt::Write;
let _ = write!(&mut s, "{b:02X}");
}
s.push('\'');
Ok(s)
}
}
}
/// List all database backup files, sorted by creation time (newest first)
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
.map_err(|e| AppError::io(&backup_dir, e))?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
.filter_map(|e| {
let metadata = e.metadata().ok()?;
let filename = e.file_name().to_string_lossy().to_string();
let size_bytes = metadata.len();
let created_at = metadata
.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<Utc> = t.into();
dt.to_rfc3339()
})
.unwrap_or_default();
Some(BackupEntry {
filename,
size_bytes,
created_at,
})
})
.collect();
// Sort by created_at descending (newest first)
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
/// Restore database from a backup file. Returns the safety backup ID.
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
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<String, AppError> {
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<OsString>);
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<u64>,
previous_page_count: Option<u64>,
}
impl RestoreLimitGuard {
fn set(vm_steps: Option<u64>, page_count: Option<u64>) -> 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<u32>);
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<Self, AppError> {
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::<Vec<_>>(),
"validator": validator,
"parents": spec.parents,
})
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<std::collections::BTreeSet<_>>();
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<Connection, 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-provider', 'pi', 'Remote Provider', '{}', '{}')",
[],
)?;
}
source.snapshot_to_memory()
}
fn exact_version_source_with_pi_provider(version: i32) -> Result<Connection, AppError> {
let source = crate::database::migration_source::exact_migration_source_for_test(version)?;
let provider_id = format!("migration-v{version}");
let settings_config =
crate::database::migration_source::pinned_pi_provider_settings_for_test(version);
if version == 1 {
source.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (
?1, 'pi', ?2, ?3, ?4, 'custom', 1700000001, 1,
'Pi migration sentinel', 'pi', '#13579b', '{}', 1
)",
rusqlite::params![
provider_id,
format!("Migration v{version}"),
settings_config,
format!("https://pi-v{version}.example")
],
)?;
source.execute_batch(
"INSERT INTO proxy_config (
id, proxy_enabled, listen_address, listen_port, enable_logging,
max_retries, streaming_first_byte_timeout,
streaming_idle_timeout, non_streaming_timeout
) VALUES (1, 1, '10.20.30.40', 23456, 0, 9, 61, 122, 603);
INSERT INTO circuit_breaker_config (
id, failure_threshold, success_threshold, timeout_seconds,
error_rate_threshold, min_requests
) VALUES (1, 7, 4, 73, 0.375, 21);
INSERT INTO settings (key, value) VALUES
('proxy_takeover_claude', 'true'),
('auto_failover_enabled_claude', 'false'),
('proxy_takeover_codex', 'false'),
('auto_failover_enabled_codex', 'true'),
('proxy_takeover_gemini', 'true'),
('auto_failover_enabled_gemini', 'true');",
)?;
} else {
source.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta,
is_current, in_failover_queue, cost_multiplier,
limit_daily_usd, limit_monthly_usd, provider_type
) VALUES (
?1, 'pi', ?2, ?3, ?4, 'custom', 1700000001, 1,
'Pi migration sentinel', 'pi', '#13579b', '{}',
1, 0, '1.25', '42.50', '420.50', 'pi-native'
)",
rusqlite::params![
provider_id,
format!("Migration v{version}"),
settings_config,
format!("https://pi-v{version}.example")
],
)?;
}
if version == SCHEMA_VERSION {
source.execute(
"INSERT INTO provider_endpoints (
id, provider_id, app_type, url, added_at, last_used
) VALUES (?1, ?2, 'pi', ?3, ?4, ?5)",
rusqlite::params![
10_000 + version,
provider_id,
format!("https://pi-endpoint-v{version}.example/v1"),
1_700_000_000 + version,
1_700_100_000 + version
],
)?;
} else {
source.execute(
"INSERT INTO provider_endpoints (
id, provider_id, app_type, url, added_at
) VALUES (?1, ?2, 'pi', ?3, ?4)",
rusqlite::params![
10_000 + version,
provider_id,
format!("https://pi-endpoint-v{version}.example/v1"),
1_700_000_000 + version
],
)?;
}
Ok(source)
}
fn actual_v16_duplicate_endpoint_source() -> Result<Connection, AppError> {
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<Connection, AppError> {
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<Connection, AppError> {
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<String, AppError> {
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<String, AppError> {
let snapshot = target.snapshot_to_memory()?;
let dump = Database::dump_sql(&snapshot, &[])?;
Ok(dump
.lines()
.filter(|line| !line.starts_with("-- 生成时间:"))
.collect::<Vec<_>>()
.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<Connection, AppError> {
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<Connection, AppError> {
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<i64, AppError> {
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<i64>, Option<i64>) = 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<i64>, Option<i64>, 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());
// A legacy v0 label must fail at the N/N-1 gate before migration DDL
// instead of being interpreted through the local-upgrade path.
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()?;
let error = run_restore_entry(
&target,
&oldest,
entry_point,
&format!("actual-v0-{entry_index}.db"),
)
.expect_err("untrusted v0 is outside the declared source-spec range");
assert!(
matches!(error, AppError::InvalidInput(_))
&& error.to_string().contains("user_version=0")
&& error.to_string().contains("备份版本过旧"),
"v0 must fail at source recognition via entry {entry_index}: {error:?}"
);
}
// 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, 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));
}
// Both supported versions are materialized from their exact source specs.
// Each public entry must preserve a real Pi provider and its endpoint;
// this cannot pass by stamping a v17 database with a v16 label.
for version in (SCHEMA_VERSION - 1)..=SCHEMA_VERSION {
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
.into_iter()
.enumerate()
{
let source = exact_version_source_with_pi_provider(version)?;
let target = Database::memory()?;
run_restore_entry(
&target,
&source,
entry_point,
&format!("exact-migration-v{version}-{entry_index}.db"),
)?;
let conn = crate::database::lock_conn!(target.conn);
let restored: (String, String, String, Option<i64>) = conn.query_row(
"SELECT p.settings_config, p.cost_multiplier, e.url, e.last_used
FROM providers AS p
JOIN provider_endpoints AS e
ON e.provider_id = p.id AND e.app_type = p.app_type
WHERE p.id = ?1 AND p.app_type = 'pi'",
[format!("migration-v{version}")],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)?;
assert!(
restored.0.contains("\"id\":\"fractional\"")
&& restored
.0
.contains(&format!("\"migrationVersion\":{version}")),
"Pi settings payload was not preserved for v{version}"
);
assert_eq!(restored.1, "1.25", "provider migration sentinel v{version}");
assert_eq!(
restored.2,
format!("https://pi-endpoint-v{version}.example/v1"),
"endpoint migration sentinel v{version}"
);
assert_eq!(
restored.3,
(version == SCHEMA_VERSION).then_some(1_700_100_000 + i64::from(version)),
"endpoint last_used 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(&regular, super::CC_SWITCH_SQL_EXPORT_HEADER)
.map_err(|error| AppError::io(&regular, error))?;
let sql_symlink = test_home.path().join("symlink.sql");
symlink(&regular, &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(&regular, &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::<Vec<_>>();
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(())
}
}