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

5362 lines
204 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 数据库备份和恢复
//!
//! 提供 SQL 导出/导入和二进制快照备份功能。
use super::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::Database(format!("execute 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}"
)));
}
self.drop_untrusted_executable_objects()?;
self.connection
.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)
.map_err(|error| AppError::Database(error.to_string()))?;
Database::create_tables_on_conn(&self.connection, MigrationRunContext::UntrustedRestore)?;
Database::apply_schema_migrations_on_conn(
&self.connection,
MigrationRunContext::UntrustedRestore,
)?;
self.connection
.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(|error| AppError::Database(error.to_string()))?;
self.enforce_scratch_size()?;
// Keep the progress/cancellation handler installed while fixed-column
// rows are decoded and copied into the canonical stage. The source is
// still untrusted during those SELECTs; dropping the handler here would
// leave the data-transfer half of restore outside the VM budget.
Ok(self)
}
/// SQLite's Backup API adopts the source page size but keeps the
/// destination's page-count limit as a raw number. A 64 KiB source would
/// therefore turn the nominal 2 GiB scratch ceiling into a 32 GiB growth
/// allowance unless the limit is rebound after cloning.
fn constrain_scratch_growth(&self) -> Result<(), AppError> {
let page_size: u64 = self
.connection
.query_row("PRAGMA page_size", [], |row| row.get(0))
.map_err(|error| AppError::Database(error.to_string()))?;
if page_size == 0 {
return Err(AppError::InvalidInput(
"restore scratch reported a zero page size".to_string(),
));
}
let byte_bounded_pages = MAX_SCRATCH_BYTES / page_size;
let requested = max_page_count().min(byte_bounded_pages);
if requested == 0 {
return Err(AppError::InvalidInput(
"restore scratch page size exceeds the byte budget".to_string(),
));
}
let applied: u64 = self
.connection
.query_row(&format!("PRAGMA max_page_count = {requested}"), [], |row| {
row.get(0)
})
.map_err(|error| AppError::Database(error.to_string()))?;
if applied > requested {
return Err(AppError::InvalidInput(format!(
"restore scratch already exceeds its {requested}-page growth budget"
)));
}
Ok(())
}
fn drop_untrusted_executable_objects(&self) -> Result<(), AppError> {
for schema in ["sqlite_schema", "sqlite_temp_schema"] {
let mut stmt = self
.connection
.prepare(&format!(
"SELECT type, name FROM {schema}
WHERE type IN ('trigger', 'view', 'index') AND sql IS NOT NULL
ORDER BY CASE type WHEN 'trigger' THEN 0 WHEN 'view' THEN 1 ELSE 2 END, name"
))
.map_err(|error| AppError::Database(error.to_string()))?;
let objects = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|error| AppError::Database(error.to_string()))?
.collect::<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,
)?;
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' AND name NOT LIKE 'sqlite_%'
ORDER BY name",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let tables = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| AppError::Database(error.to_string()))?
.collect::<Result<std::collections::BTreeSet<_>, _>>()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(tables)
}
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()))?;
// 跳过 SQLite 内部对象(如 sqlite_sequence
if name.starts_with("sqlite_") {
continue;
}
output.push_str(&sql);
output.push_str(";\n");
if obj_type == "table" && !name.starts_with("sqlite_") {
tables.push(name);
}
}
// 导出数据
for table in tables {
if skip_tables.iter().any(|t| *t == table) {
continue;
}
let columns = Self::get_table_columns(conn, &table)?;
if columns.is_empty() {
continue;
}
let mut stmt = conn
.prepare(&format!("SELECT * FROM \"{table}\""))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
if table == "settings"
&& row
.get_ref(0)
.ok()
.and_then(|value| match value {
ValueRef::Text(key) => std::str::from_utf8(key).ok(),
_ => None,
})
.is_some_and(is_device_local_setting_key)
{
continue;
}
let mut values = Vec::with_capacity(columns.len());
for idx in 0..columns.len() {
let value = row
.get_ref(idx)
.map_err(|e| AppError::Database(e.to_string()))?;
values.push(Self::format_sql_value(value)?);
}
let cols = columns
.iter()
.map(|c| format!("\"{c}\""))
.collect::<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)
}
/// 获取表的列名列表
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 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());
// Exercise the oldest supported layout, rather than merely relabeling
// a current schema. Missing columns must be supplied by the real
// v0..current migration chain before fixed-column restore begins.
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
.into_iter()
.enumerate()
{
let oldest = Connection::open_in_memory()?;
oldest.execute_batch(
"CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL DEFAULT '{}',
website_url TEXT,
PRIMARY KEY (id, app_type)
);
INSERT INTO providers (
id, app_type, name, settings_config, website_url
) VALUES (
'actual-v0-provider', 'pi', 'Actual v0 Provider', '{}', NULL
);
PRAGMA user_version = 0;",
)?;
let target = Database::memory()?;
run_restore_entry(
&target,
&oldest,
entry_point,
&format!("actual-v0-{entry_index}.db"),
)?;
let conn = crate::database::lock_conn!(target.conn);
let restored: i64 = conn.query_row(
"SELECT COUNT(*) FROM providers
WHERE id = 'actual-v0-provider'
AND app_type = 'pi'
AND cost_multiplier = '1.0'",
[],
|row| row.get(0),
)?;
assert_eq!(restored, 1, "actual oldest-layout migration sentinel");
}
// Exercise the real immediately-previous v16 shape. In particular,
// these columns and device-local tables did not exist yet; relabeling
// a v17 database cannot prove that the migration supplies them.
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
.into_iter()
.enumerate()
{
let previous = canonical_restore_source()?;
previous.execute_batch(
"INSERT INTO provider_endpoints
(provider_id, app_type, url, added_at, last_used)
VALUES ('remote-provider', 'pi', 'https://v16.invalid', 16, NULL);
INSERT INTO skills (
id, name, directory, enabled_codex, enabled_pi,
installed_at, updated_at
) VALUES ('actual-v16-skill', 'Actual v16 Skill', '/v16', 1, 0, 16, 16);
PRAGMA foreign_keys = OFF;
DROP TABLE pi_provider_projections;
DROP TABLE skill_deployments;
ALTER TABLE provider_endpoints DROP COLUMN last_used;
ALTER TABLE skills DROP COLUMN enabled_pi;
PRAGMA user_version = 16;",
)?;
let target = Database::memory()?;
run_restore_entry(
&target,
&previous,
entry_point,
&format!("actual-v16-{entry_index}.db"),
)?;
let conn = crate::database::lock_conn!(target.conn);
let sentinel: (Option<i64>, i64, i64, i64) = conn.query_row(
"SELECT
(SELECT last_used FROM provider_endpoints
WHERE provider_id = 'remote-provider'
AND app_type = 'pi'
AND url = 'https://v16.invalid'),
(SELECT enabled_pi FROM skills WHERE id = 'actual-v16-skill'),
(SELECT COUNT(*) FROM pi_provider_projections),
(SELECT COUNT(*) FROM skill_deployments)",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)?;
assert_eq!(sentinel, (None, 0, 0, 0));
}
// Every version still gets a public dispatch sentinel. The separate
// historical-layout cases above prevent this matrix from passing only
// because current tables were stamped with an older user_version.
for version in 0..=SCHEMA_VERSION {
let source_db = Database::memory()?;
{
let conn = crate::database::lock_conn!(source_db.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES (?1, 'pi', ?2, '{}', '{}')",
rusqlite::params![
format!("migration-v{version}"),
format!("Migration v{version}")
],
)?;
Database::set_user_version(&conn, version)?;
}
let source = source_db.snapshot_to_memory()?;
let target = Database::memory()?;
run_restore_entry(
&target,
&source,
RestoreEntryPoint::Sql,
&format!("unused-migration-v{version}.db"),
)?;
let conn = crate::database::lock_conn!(target.conn);
let restored: i64 = conn.query_row(
"SELECT COUNT(*) FROM providers WHERE id = ?1 AND app_type = 'pi'",
[format!("migration-v{version}")],
|row| row.get(0),
)?;
assert_eq!(restored, 1, "SQL migration sentinel v{version}");
}
for version in [0, SCHEMA_VERSION - 1, SCHEMA_VERSION] {
let source_db = Database::memory()?;
{
let conn = crate::database::lock_conn!(source_db.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES (?1, 'pi', ?2, '{}', '{}')",
rusqlite::params![
format!("binary-migration-v{version}"),
format!("Binary migration v{version}")
],
)?;
Database::set_user_version(&conn, version)?;
}
let source = source_db.snapshot_to_memory()?;
let target = Database::memory()?;
run_restore_entry(
&target,
&source,
RestoreEntryPoint::Binary,
&format!("migration-v{version}.db"),
)?;
let conn = crate::database::lock_conn!(target.conn);
let restored: i64 = conn.query_row(
"SELECT COUNT(*) FROM providers WHERE id = ?1 AND app_type = 'pi'",
[format!("binary-migration-v{version}")],
|row| row.get(0),
)?;
assert_eq!(restored, 1, "binary migration sentinel v{version}");
}
Ok(())
}
#[test]
fn import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> {
// `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样,
// 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。
let cases: [(&str, &str); 2] = [
("attach", "ATTACH DATABASE '{path}' AS evil;"),
("vacuum-into", "VACUUM INTO '{path}';"),
];
for (label, template) in cases {
let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite"));
let _ = std::fs::remove_file(&target);
// 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它,
// 真正拦下来的必须是 authorizer。
let malicious = format!(
"{}\n{}\n",
super::CC_SWITCH_SQL_EXPORT_HEADER,
template.replace("{path}", &target.display().to_string())
);
let db = Database::memory()?;
let result = db.import_sql_string(&malicious);
assert!(result.is_err(), "{label} 必须被拒绝");
// 光报错不够:文件创建发生在 prepare 之后、canonical validation 之前,
// 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。
assert!(
!target.exists(),
"被拒绝的 {label} 不得在磁盘上留下文件: {}",
target.display()
);
let _ = std::fs::remove_file(&target);
}
Ok(())
}
#[test]
fn public_sql_restore_rejects_temp_schema_growth_atomically() -> Result<(), AppError> {
let malicious = format!(
"{}\n\
CREATE TEMP TABLE scratch_escape(payload BLOB);\n\
INSERT INTO scratch_escape(payload) VALUES (zeroblob(67108863));\n\
INSERT INTO scratch_escape(payload) SELECT payload FROM scratch_escape;",
super::CC_SWITCH_SQL_EXPORT_HEADER
);
for sync in [false, true] {
let database = Database::memory()?;
seed_live_restore_state(&database)?;
let before = logical_snapshot(&database)?;
let result = if sync {
database.import_sql_string_for_sync(&malicious)
} else {
database.import_sql_string(&malicious)
};
assert!(
result.is_err(),
"TEMP schema DDL must be rejected by the public {} SQL entry",
if sync { "sync" } else { "user" }
);
assert_eq!(
logical_snapshot(&database)?,
before,
"rejected TEMP growth changed live state"
);
}
Ok(())
}
#[test]
#[serial]
fn public_file_restore_entries_reject_symlink_directory_and_fifo() -> Result<(), AppError> {
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
context: "create restore file-shape home".to_string(),
source: error,
})?;
let _home_guard = TestHomeGuard::set(test_home.path());
let database = Database::memory()?;
let backup_dir = crate::config::get_app_config_dir().join("backups");
for filename in ["C:outside.db", "backup.db:stream.db", "../outside.db"] {
assert!(
database.restore_from_backup(filename).is_err(),
"restore filename must be exactly one portable normal component"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let external = test_home.path().join("external-backups");
fs::create_dir_all(&external).map_err(|error| AppError::io(&external, error))?;
fs::create_dir_all(
backup_dir
.parent()
.ok_or_else(|| AppError::InvalidInput("backup parent missing".to_string()))?,
)
.map_err(|error| AppError::io(&backup_dir, error))?;
fs::write(external.join("outside.db"), b"outside")
.map_err(|error| AppError::io(&external, error))?;
symlink(&external, &backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
assert!(
database.restore_from_backup("outside.db").is_err(),
"a symlinked backup directory must not authorize an outside source"
);
fs::remove_file(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
}
fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
let sql_directory = test_home.path().join("sql-directory");
fs::create_dir(&sql_directory).map_err(|error| AppError::io(&sql_directory, error))?;
assert!(database.import_sql(&sql_directory).is_err());
let binary_directory = backup_dir.join("binary-directory.db");
fs::create_dir(&binary_directory)
.map_err(|error| AppError::io(&binary_directory, error))?;
assert!(database.restore_from_backup("binary-directory.db").is_err());
#[cfg(unix)]
{
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::symlink;
let regular = test_home.path().join("regular-source");
fs::write(&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(())
}
}