mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
5454 lines
208 KiB
Rust
5454 lines
208 KiB
Rust
//! 数据库备份和恢复
|
|
//!
|
|
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
|
|
|
use super::schema::{CanonicalStage, MigrationRunContext};
|
|
use super::{lock_conn, Database, SCHEMA_VERSION};
|
|
use crate::config::get_app_config_dir;
|
|
use crate::error::AppError;
|
|
use chrono::{Local, Utc};
|
|
use rusqlite::backup::{Backup, StepResult};
|
|
use rusqlite::config::DbConfig;
|
|
use rusqlite::limits::Limit;
|
|
use rusqlite::types::{Value, ValueRef};
|
|
use rusqlite::{Connection, OpenFlags, OptionalExtension};
|
|
use std::fs::{self, File, Metadata, OpenOptions};
|
|
use std::io::{Read, Take, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tempfile::NamedTempFile;
|
|
|
|
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
|
|
|
pub(crate) const MAX_SQL_IMPORT_BYTES: u64 = 256 * 1024 * 1024;
|
|
pub(crate) const MAX_BINARY_RESTORE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
|
pub(crate) const MAX_SCRATCH_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
|
const MAX_SQL_VALUE_BYTES: i32 = 64 * 1024 * 1024;
|
|
const MAX_VM_STEPS: u64 = 50_000_000;
|
|
const PROGRESS_GRANULARITY: u64 = 1_000;
|
|
const MAX_PAGE_COUNT: u64 = 524_288;
|
|
const BACKUP_PAGES_PER_STEP: i32 = 256;
|
|
const MAX_BACKUP_TRANSIENT_RETRIES: u32 = 100;
|
|
const MAX_BACKUP_STEPS: u32 = 100_000;
|
|
const BACKUP_RETRY_DELAY: Duration = Duration::from_millis(10);
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
static TEST_MAX_VM_STEPS: std::cell::Cell<Option<u64>> =
|
|
const { std::cell::Cell::new(None) };
|
|
static TEST_MAX_PAGE_COUNT: std::cell::Cell<Option<u64>> =
|
|
const { std::cell::Cell::new(None) };
|
|
static TEST_MAX_BACKUP_TRANSIENT_RETRIES: std::cell::Cell<Option<u32>> =
|
|
const { std::cell::Cell::new(None) };
|
|
static TEST_AFTER_SAFETY_BACKUP:
|
|
std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
|
|
const { std::cell::RefCell::new(None) };
|
|
}
|
|
|
|
fn max_vm_steps() -> u64 {
|
|
#[cfg(test)]
|
|
if let Some(limit) = TEST_MAX_VM_STEPS.with(std::cell::Cell::get) {
|
|
return limit;
|
|
}
|
|
MAX_VM_STEPS
|
|
}
|
|
|
|
fn max_page_count() -> u64 {
|
|
#[cfg(test)]
|
|
if let Some(limit) = TEST_MAX_PAGE_COUNT.with(std::cell::Cell::get) {
|
|
return limit;
|
|
}
|
|
MAX_PAGE_COUNT
|
|
}
|
|
|
|
fn max_backup_transient_retries() -> u32 {
|
|
#[cfg(test)]
|
|
if let Some(limit) = TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(std::cell::Cell::get) {
|
|
return limit;
|
|
}
|
|
MAX_BACKUP_TRANSIENT_RETRIES
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn run_after_safety_backup_test_seam() {
|
|
if let Some(hook) = TEST_AFTER_SAFETY_BACKUP.with(|slot| slot.borrow_mut().take()) {
|
|
hook();
|
|
}
|
|
}
|
|
|
|
/// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory`
|
|
/// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。
|
|
const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"];
|
|
|
|
/// 执行外部 SQL 期间的 authorizer:拒绝一切能**离开临时数据库文件**的动作。
|
|
///
|
|
/// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在
|
|
/// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在
|
|
/// canonical data validation 之前,导入即使最终失败,文件也已经被创建;而 `settings`
|
|
/// 表不在同步 skip/commit-boundary overlay 之列,WebDAV/S3 同步会走
|
|
/// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。
|
|
///
|
|
/// 为什么是 authorizer 而不是「扫描 ATTACH 关键字」:字符串扫描会被 `/*x*/ATTACH`、
|
|
/// 大小写、换行绕过,还漏掉 `VACUUM INTO`。authorizer 在 prepare 阶段按**解析结果**
|
|
/// 回调,绕不过语法层。
|
|
///
|
|
/// 为什么是「拒绝越界动作」而不是「只放行 dump_sql 的语句」:这段 SQL 跑在
|
|
/// `NamedTempFile` 建的一次性库上,而那个库的全部内容本来就由这份 SQL 决定。
|
|
/// 因此 `DELETE` / `DROP` / `UPDATE` 给不了攻击者任何新东西——**唯一有意义的边界
|
|
/// 是那个临时文件本身**。按 dump_sql 的产物做严格白名单只会带来误伤风险(用户
|
|
/// 库里出现一种没预料到的对象就恢复不了备份),却不多挡任何攻击。
|
|
///
|
|
/// 越界动作是实测出来的,不是推断的:
|
|
/// - `ATTACH DATABASE 'x'`、`VACUUM INTO 'x'`、裸 `VACUUM` **三者都**报
|
|
/// `AuthAction::Attach`,所以拒 `Attach` 一条即可覆盖
|
|
/// - 文件后端的虚拟表模块(`csvfile`、`zipfile` 等)能读写任意路径 → 拒 vtable
|
|
/// - `Unknown` 是 rusqlite 对未识别动作码的兜底 → 未知即拒,将来 SQLite 新增的
|
|
/// 跨文件语句会默认落进这里,不依赖有人记得回来补名单
|
|
fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization {
|
|
use rusqlite::hooks::{AuthAction, Authorization};
|
|
|
|
let escapes_scratch_boundary = context
|
|
.database_name
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("temp"))
|
|
|| match context.action {
|
|
AuthAction::Attach { .. } | AuthAction::Detach { .. } => true,
|
|
AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true,
|
|
AuthAction::CreateTempIndex { .. }
|
|
| AuthAction::CreateTempTable { .. }
|
|
| AuthAction::CreateTempTrigger { .. }
|
|
| AuthAction::CreateTempView { .. }
|
|
| AuthAction::DropTempIndex { .. }
|
|
| AuthAction::DropTempTable { .. }
|
|
| AuthAction::DropTempTrigger { .. }
|
|
| AuthAction::DropTempView { .. } => true,
|
|
// Genuine exports can contain expression indexes (for example,
|
|
// COALESCE in the request-log dedupe index), so ordinary built-ins
|
|
// must remain usable while the untrusted schema is assembled.
|
|
// No application functions are registered on this connection and
|
|
// extension loading is never enabled; deny the SQL entry point too.
|
|
AuthAction::Function { function_name } => {
|
|
function_name.eq_ignore_ascii_case("load_extension")
|
|
}
|
|
AuthAction::Unknown { .. } => true,
|
|
AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS
|
|
.iter()
|
|
.any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)),
|
|
_ => false,
|
|
};
|
|
|
|
if escapes_scratch_boundary {
|
|
// SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。
|
|
log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action);
|
|
Authorization::Deny
|
|
} else {
|
|
Authorization::Allow
|
|
}
|
|
}
|
|
|
|
/// Tables whose data rows are skipped when exporting for WebDAV sync.
|
|
const SYNC_SKIP_TABLES: &[&str] = &[
|
|
"proxy_request_logs",
|
|
"stream_check_logs",
|
|
"provider_health",
|
|
"proxy_live_backup",
|
|
"usage_daily_rollups",
|
|
"session_log_sync",
|
|
"pi_provider_projections",
|
|
"skill_deployments",
|
|
];
|
|
|
|
/// Exact file/deployment ownership belongs to the current device. Portable SQL
|
|
/// exports carry table shape but never rows, and imports restore live rows.
|
|
const DEVICE_LOCAL_TABLES: &[&str] = &[
|
|
"session_log_sync",
|
|
"pi_provider_projections",
|
|
"skill_deployments",
|
|
];
|
|
|
|
/// Gateway credentials are installation-local even though legacy releases
|
|
/// stored them in the otherwise-portable key/value table. Neither SQL export
|
|
/// flavor nor an untrusted restore source may transfer these rows. The live
|
|
/// value is copied at the publication boundary until its owning subsystem has
|
|
/// durably migrated it out of SQLite.
|
|
const DEVICE_LOCAL_SETTING_KEYS: &[&str] = &["claude_desktop_gateway_token", "pi_gateway_token"];
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RestorePolicy {
|
|
PortableIncoming,
|
|
PreserveLive,
|
|
RebuildRuntime,
|
|
SeedCanonical,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum StorageKind {
|
|
Text,
|
|
Integer,
|
|
Real,
|
|
}
|
|
|
|
/// Semantic range of an INTEGER column at the production hydration boundary.
|
|
///
|
|
/// SQLite stores every INTEGER as an `i64`, while several public projections
|
|
/// narrow those values to `bool`, `u8`, `u16`, `u32`, `u64`, or `usize`.
|
|
/// Restore must reject values that those projections would wrap or reinterpret
|
|
/// instead of publishing a database that fails only when the row is later read.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum IntegerDomain {
|
|
Unrestricted,
|
|
Boolean,
|
|
NonNegative,
|
|
SortIndex,
|
|
Unsigned8,
|
|
Unsigned16,
|
|
NonNegativeI32,
|
|
Unsigned32,
|
|
InputTokenSemantics,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RealDomain {
|
|
NotReal,
|
|
FiniteUnitInterval,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct RestoreColumnSpec {
|
|
name: &'static str,
|
|
storage: StorageKind,
|
|
nullable: bool,
|
|
integer_domain: IntegerDomain,
|
|
real_domain: RealDomain,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RestoreRowValidator {
|
|
/// Storage/nullability is the complete portable contract for this row;
|
|
/// no JSON or decimal domain decoding is intentionally required.
|
|
OpaqueStorage,
|
|
Provider,
|
|
Mcp,
|
|
Profile,
|
|
ProxyConfig,
|
|
NonNegativeDecimalColumns(&'static [usize]),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct RestoreTableSpec {
|
|
name: &'static str,
|
|
policy: RestorePolicy,
|
|
columns: &'static [RestoreColumnSpec],
|
|
validator: RestoreRowValidator,
|
|
parents: &'static [&'static str],
|
|
}
|
|
|
|
macro_rules! text_col {
|
|
($name:literal) => {
|
|
RestoreColumnSpec {
|
|
name: $name,
|
|
storage: StorageKind::Text,
|
|
nullable: false,
|
|
integer_domain: IntegerDomain::Unrestricted,
|
|
real_domain: RealDomain::NotReal,
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! nullable_text_col {
|
|
($name:literal) => {
|
|
RestoreColumnSpec {
|
|
name: $name,
|
|
storage: StorageKind::Text,
|
|
nullable: true,
|
|
integer_domain: IntegerDomain::Unrestricted,
|
|
real_domain: RealDomain::NotReal,
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! integer_col {
|
|
($name:literal, $domain:ident) => {
|
|
RestoreColumnSpec {
|
|
name: $name,
|
|
storage: StorageKind::Integer,
|
|
nullable: false,
|
|
integer_domain: IntegerDomain::$domain,
|
|
real_domain: RealDomain::NotReal,
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! nullable_integer_col {
|
|
($name:literal, $domain:ident) => {
|
|
RestoreColumnSpec {
|
|
name: $name,
|
|
storage: StorageKind::Integer,
|
|
nullable: true,
|
|
integer_domain: IntegerDomain::$domain,
|
|
real_domain: RealDomain::NotReal,
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! real_col {
|
|
($name:literal, $domain:ident) => {
|
|
RestoreColumnSpec {
|
|
name: $name,
|
|
storage: StorageKind::Real,
|
|
nullable: false,
|
|
integer_domain: IntegerDomain::Unrestricted,
|
|
real_domain: RealDomain::$domain,
|
|
}
|
|
};
|
|
}
|
|
|
|
const PROVIDERS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("id"),
|
|
text_col!("app_type"),
|
|
text_col!("name"),
|
|
text_col!("settings_config"),
|
|
nullable_text_col!("website_url"),
|
|
nullable_text_col!("category"),
|
|
nullable_integer_col!("created_at", Unrestricted),
|
|
nullable_integer_col!("sort_index", SortIndex),
|
|
nullable_text_col!("notes"),
|
|
nullable_text_col!("icon"),
|
|
nullable_text_col!("icon_color"),
|
|
text_col!("meta"),
|
|
integer_col!("is_current", Boolean),
|
|
integer_col!("in_failover_queue", Boolean),
|
|
text_col!("cost_multiplier"),
|
|
nullable_text_col!("limit_daily_usd"),
|
|
nullable_text_col!("limit_monthly_usd"),
|
|
nullable_text_col!("provider_type"),
|
|
];
|
|
|
|
const PROVIDER_ENDPOINTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
// Explicit INTEGER PRIMARY KEY values are portable data. SQLite permits
|
|
// negative explicit IDs even though AUTOINCREMENT only generates positive
|
|
// values, so restore must preserve the full i64 domain.
|
|
integer_col!("id", Unrestricted),
|
|
text_col!("provider_id"),
|
|
text_col!("app_type"),
|
|
text_col!("url"),
|
|
nullable_integer_col!("added_at", Unrestricted),
|
|
nullable_integer_col!("last_used", Unrestricted),
|
|
];
|
|
|
|
const MCP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("id"),
|
|
text_col!("name"),
|
|
text_col!("server_config"),
|
|
nullable_text_col!("description"),
|
|
nullable_text_col!("homepage"),
|
|
nullable_text_col!("docs"),
|
|
text_col!("tags"),
|
|
integer_col!("enabled_claude", Boolean),
|
|
integer_col!("enabled_codex", Boolean),
|
|
integer_col!("enabled_gemini", Boolean),
|
|
integer_col!("enabled_grokbuild", Boolean),
|
|
integer_col!("enabled_opencode", Boolean),
|
|
integer_col!("enabled_hermes", Boolean),
|
|
];
|
|
|
|
const PROMPTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("id"),
|
|
text_col!("app_type"),
|
|
text_col!("name"),
|
|
text_col!("content"),
|
|
nullable_text_col!("description"),
|
|
integer_col!("enabled", Boolean),
|
|
nullable_integer_col!("created_at", Unrestricted),
|
|
nullable_integer_col!("updated_at", Unrestricted),
|
|
];
|
|
|
|
const SKILLS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("id"),
|
|
text_col!("name"),
|
|
nullable_text_col!("description"),
|
|
text_col!("directory"),
|
|
nullable_text_col!("repo_owner"),
|
|
nullable_text_col!("repo_name"),
|
|
nullable_text_col!("repo_branch"),
|
|
nullable_text_col!("readme_url"),
|
|
integer_col!("enabled_claude", Boolean),
|
|
integer_col!("enabled_codex", Boolean),
|
|
integer_col!("enabled_gemini", Boolean),
|
|
integer_col!("enabled_grokbuild", Boolean),
|
|
integer_col!("enabled_opencode", Boolean),
|
|
integer_col!("enabled_hermes", Boolean),
|
|
integer_col!("enabled_pi", Boolean),
|
|
integer_col!("installed_at", Unrestricted),
|
|
nullable_text_col!("content_hash"),
|
|
integer_col!("updated_at", Unrestricted),
|
|
];
|
|
|
|
const SKILL_REPOS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("owner"),
|
|
text_col!("name"),
|
|
text_col!("branch"),
|
|
integer_col!("enabled", Boolean),
|
|
];
|
|
|
|
const SETTINGS_RESTORE_COLUMNS: &[RestoreColumnSpec] =
|
|
&[text_col!("key"), nullable_text_col!("value")];
|
|
|
|
const PROXY_CONFIG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("app_type"),
|
|
integer_col!("proxy_enabled", Boolean),
|
|
text_col!("listen_address"),
|
|
integer_col!("listen_port", Unsigned16),
|
|
integer_col!("enable_logging", Boolean),
|
|
integer_col!("enabled", Boolean),
|
|
integer_col!("auto_failover_enabled", Boolean),
|
|
integer_col!("max_retries", Unsigned8),
|
|
integer_col!("streaming_first_byte_timeout", NonNegativeI32),
|
|
integer_col!("streaming_idle_timeout", NonNegativeI32),
|
|
integer_col!("non_streaming_timeout", NonNegativeI32),
|
|
integer_col!("circuit_failure_threshold", NonNegativeI32),
|
|
integer_col!("circuit_success_threshold", NonNegativeI32),
|
|
integer_col!("circuit_timeout_seconds", NonNegativeI32),
|
|
real_col!("circuit_error_rate_threshold", FiniteUnitInterval),
|
|
integer_col!("circuit_min_requests", NonNegativeI32),
|
|
text_col!("default_cost_multiplier"),
|
|
text_col!("pricing_model_source"),
|
|
integer_col!("live_takeover_active", Boolean),
|
|
text_col!("created_at"),
|
|
text_col!("updated_at"),
|
|
];
|
|
|
|
const PROVIDER_HEALTH_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("provider_id"),
|
|
text_col!("app_type"),
|
|
integer_col!("is_healthy", Boolean),
|
|
integer_col!("consecutive_failures", Unsigned32),
|
|
nullable_text_col!("last_success_at"),
|
|
nullable_text_col!("last_failure_at"),
|
|
nullable_text_col!("last_error"),
|
|
text_col!("updated_at"),
|
|
];
|
|
|
|
const PROXY_LOG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("request_id"),
|
|
text_col!("provider_id"),
|
|
text_col!("app_type"),
|
|
text_col!("model"),
|
|
nullable_text_col!("request_model"),
|
|
nullable_text_col!("pricing_model"),
|
|
integer_col!("input_tokens", Unsigned32),
|
|
integer_col!("output_tokens", Unsigned32),
|
|
integer_col!("cache_read_tokens", Unsigned32),
|
|
integer_col!("cache_creation_tokens", Unsigned32),
|
|
integer_col!("input_token_semantics", InputTokenSemantics),
|
|
text_col!("input_cost_usd"),
|
|
text_col!("output_cost_usd"),
|
|
text_col!("cache_read_cost_usd"),
|
|
text_col!("cache_creation_cost_usd"),
|
|
text_col!("total_cost_usd"),
|
|
integer_col!("latency_ms", NonNegative),
|
|
nullable_integer_col!("first_token_ms", NonNegative),
|
|
nullable_integer_col!("duration_ms", NonNegative),
|
|
integer_col!("status_code", Unsigned16),
|
|
nullable_text_col!("error_message"),
|
|
nullable_text_col!("session_id"),
|
|
nullable_text_col!("provider_type"),
|
|
integer_col!("is_streaming", Boolean),
|
|
text_col!("cost_multiplier"),
|
|
integer_col!("created_at", Unrestricted),
|
|
text_col!("data_source"),
|
|
];
|
|
|
|
const MODEL_PRICING_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("model_id"),
|
|
text_col!("display_name"),
|
|
text_col!("input_cost_per_million"),
|
|
text_col!("output_cost_per_million"),
|
|
text_col!("cache_read_cost_per_million"),
|
|
text_col!("cache_creation_cost_per_million"),
|
|
];
|
|
|
|
const STREAM_LOG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
integer_col!("id", Unrestricted),
|
|
text_col!("provider_id"),
|
|
text_col!("provider_name"),
|
|
text_col!("app_type"),
|
|
text_col!("status"),
|
|
integer_col!("success", Boolean),
|
|
text_col!("message"),
|
|
nullable_integer_col!("response_time_ms", NonNegative),
|
|
nullable_integer_col!("http_status", Unsigned16),
|
|
nullable_text_col!("model_used"),
|
|
nullable_integer_col!("retry_count", Unsigned32),
|
|
integer_col!("tested_at", Unrestricted),
|
|
];
|
|
|
|
const PROXY_LIVE_BACKUP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("app_type"),
|
|
text_col!("original_config"),
|
|
text_col!("backed_up_at"),
|
|
];
|
|
|
|
const USAGE_ROLLUP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("date"),
|
|
text_col!("app_type"),
|
|
text_col!("provider_id"),
|
|
text_col!("model"),
|
|
text_col!("request_model"),
|
|
text_col!("pricing_model"),
|
|
integer_col!("request_count", NonNegative),
|
|
integer_col!("success_count", NonNegative),
|
|
integer_col!("input_tokens", NonNegative),
|
|
integer_col!("output_tokens", NonNegative),
|
|
integer_col!("cache_read_tokens", NonNegative),
|
|
integer_col!("cache_creation_tokens", NonNegative),
|
|
integer_col!("input_token_semantics", InputTokenSemantics),
|
|
text_col!("total_cost_usd"),
|
|
integer_col!("avg_latency_ms", NonNegative),
|
|
];
|
|
|
|
const SESSION_SYNC_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("file_path"),
|
|
integer_col!("last_modified", Unrestricted),
|
|
integer_col!("last_line_offset", NonNegative),
|
|
integer_col!("last_synced_at", Unrestricted),
|
|
];
|
|
|
|
const PROFILE_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("id"),
|
|
text_col!("name"),
|
|
text_col!("payload"),
|
|
nullable_integer_col!("sort_order", Unrestricted),
|
|
nullable_integer_col!("created_at", Unrestricted),
|
|
nullable_integer_col!("updated_at", Unrestricted),
|
|
];
|
|
|
|
const PI_PROJECTION_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("provider_id"),
|
|
text_col!("provider_key"),
|
|
integer_col!("created_at", Unrestricted),
|
|
integer_col!("updated_at", Unrestricted),
|
|
];
|
|
|
|
const SKILL_DEPLOYMENT_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
|
|
text_col!("app_type"),
|
|
text_col!("skill_id"),
|
|
text_col!("destination"),
|
|
text_col!("destination_key"),
|
|
text_col!("method"),
|
|
text_col!("source_identity"),
|
|
nullable_text_col!("deployed_digest"),
|
|
integer_col!("created_at", Unrestricted),
|
|
integer_col!("updated_at", Unrestricted),
|
|
];
|
|
|
|
/// Parent-before-child order is also the canonical copy order.
|
|
const RESTORE_TABLE_SPECS: &[RestoreTableSpec] = &[
|
|
RestoreTableSpec {
|
|
name: "providers",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROVIDERS_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::Provider,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "provider_endpoints",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROVIDER_ENDPOINTS_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &["providers"],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "mcp_servers",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: MCP_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::Mcp,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "prompts",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROMPTS_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "skills",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: SKILLS_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "skill_repos",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: SKILL_REPOS_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "settings",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: SETTINGS_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "proxy_config",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROXY_CONFIG_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::ProxyConfig,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "provider_health",
|
|
policy: RestorePolicy::RebuildRuntime,
|
|
columns: PROVIDER_HEALTH_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &["providers"],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "proxy_request_logs",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROXY_LOG_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::NonNegativeDecimalColumns(&[11, 12, 13, 14, 15, 24]),
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "model_pricing",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: MODEL_PRICING_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::NonNegativeDecimalColumns(&[2, 3, 4, 5]),
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "stream_check_logs",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: STREAM_LOG_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "proxy_live_backup",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROXY_LIVE_BACKUP_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "usage_daily_rollups",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: USAGE_ROLLUP_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::NonNegativeDecimalColumns(&[13]),
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "session_log_sync",
|
|
policy: RestorePolicy::PreserveLive,
|
|
columns: SESSION_SYNC_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "profiles",
|
|
policy: RestorePolicy::PortableIncoming,
|
|
columns: PROFILE_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::Profile,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "pi_provider_projections",
|
|
policy: RestorePolicy::PreserveLive,
|
|
columns: PI_PROJECTION_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
RestoreTableSpec {
|
|
name: "skill_deployments",
|
|
policy: RestorePolicy::PreserveLive,
|
|
columns: SKILL_DEPLOYMENT_RESTORE_COLUMNS,
|
|
validator: RestoreRowValidator::OpaqueStorage,
|
|
parents: &[],
|
|
},
|
|
];
|
|
|
|
const SYNC_LIVE_OVERLAY_TABLES: &[&str] = &[
|
|
"proxy_request_logs",
|
|
"stream_check_logs",
|
|
"proxy_live_backup",
|
|
"usage_daily_rollups",
|
|
];
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RestoreFlavor {
|
|
UserRestore,
|
|
Sync,
|
|
}
|
|
|
|
/// An untrusted schema can only exist behind this private wrapper. There is no
|
|
/// conversion from it to CanonicalStage.
|
|
struct UntrustedScratch {
|
|
connection: Connection,
|
|
_file: NamedTempFile,
|
|
cancellation: Arc<AtomicBool>,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn open_nofollow(path: &Path) -> std::io::Result<File> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
OpenOptions::new()
|
|
.read(true)
|
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
|
.open(path)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn open_nofollow(path: &Path) -> std::io::Result<File> {
|
|
use std::os::windows::fs::OpenOptionsExt;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
|
|
|
|
OpenOptions::new()
|
|
.read(true)
|
|
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
|
.open(path)
|
|
}
|
|
|
|
#[cfg(all(not(unix), not(windows)))]
|
|
fn open_nofollow(path: &Path) -> std::io::Result<File> {
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::Unsupported,
|
|
format!(
|
|
"nofollow restore-source opens are unsupported on this platform: {}",
|
|
path.display()
|
|
),
|
|
))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn same_file_identity(opened: &Metadata, current: &Metadata) -> bool {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
opened.dev() == current.dev() && opened.ino() == current.ino()
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result<bool> {
|
|
let opened = opened.metadata()?;
|
|
let current = current.metadata()?;
|
|
Ok(same_file_identity(&opened, ¤t))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_file_identity(file: &File) -> std::io::Result<(u64, [u8; 16])> {
|
|
use std::os::windows::io::AsRawHandle;
|
|
use windows_sys::Win32::Storage::FileSystem::{
|
|
FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO,
|
|
};
|
|
|
|
let mut information = FILE_ID_INFO::default();
|
|
// SAFETY: `file` owns a live handle for this call, and `information` is a
|
|
// valid writable FILE_ID_INFO buffer of the size passed to Windows.
|
|
let succeeded = unsafe {
|
|
GetFileInformationByHandleEx(
|
|
file.as_raw_handle(),
|
|
FileIdInfo,
|
|
std::ptr::addr_of_mut!(information).cast(),
|
|
std::mem::size_of::<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, ¤t_file)
|
|
.map_err(|error| AppError::io(path, error))?;
|
|
if !current_path.file_type().is_file()
|
|
|| !current_opened.file_type().is_file()
|
|
|| !same_identity
|
|
|| opened.len() != consumed_len
|
|
|| completed.len() != consumed_len
|
|
|| current_path.len() != consumed_len
|
|
|| current_opened.len() != consumed_len
|
|
|| opened.modified().ok() != completed.modified().ok()
|
|
{
|
|
return Err(AppError::InvalidInput(format!(
|
|
"restore source changed while it was read: {}",
|
|
path.display()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_regular_file(path: &Path, max_bytes: u64) -> Result<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, ¤t).map_err(|error| AppError::io(path, error))?;
|
|
let same_directory = same_open_file_identity(&directory, ¤t_directory)
|
|
.map_err(|error| AppError::io(directory_path, error))?;
|
|
if !same_source
|
|
|| !same_directory
|
|
|| metadata_is_reparse_point(&completed)
|
|
|| metadata_is_reparse_point(¤t_metadata)
|
|
|| opened.len() != copied
|
|
|| completed.len() != copied
|
|
|| current_metadata.len() != copied
|
|
|| opened.modified().ok() != completed.modified().ok()
|
|
{
|
|
return Err(AppError::InvalidInput(format!(
|
|
"binary restore source changed while it was read: {}",
|
|
path.display()
|
|
)));
|
|
}
|
|
Ok(owned)
|
|
}
|
|
|
|
fn run_backup_to_completion(backup: &Backup<'_, '_>, context: &str) -> Result<(), AppError> {
|
|
let mut transient_retries = 0_u32;
|
|
let mut total_steps = 0_u32;
|
|
loop {
|
|
if total_steps >= MAX_BACKUP_STEPS {
|
|
let progress = backup.progress();
|
|
return Err(AppError::Database(format!(
|
|
"{context}: SQLite backup exceeded {MAX_BACKUP_STEPS} bounded steps \
|
|
(remaining {}, total {})",
|
|
progress.remaining, progress.pagecount
|
|
)));
|
|
}
|
|
total_steps += 1;
|
|
let result = backup
|
|
.step(BACKUP_PAGES_PER_STEP)
|
|
.map_err(|error| AppError::Database(format!("{context}: {error}")))?;
|
|
match result {
|
|
StepResult::Done => return Ok(()),
|
|
StepResult::More => transient_retries = 0,
|
|
StepResult::Busy | StepResult::Locked => {
|
|
if transient_retries >= max_backup_transient_retries() {
|
|
let progress = backup.progress();
|
|
return Err(AppError::Database(format!(
|
|
"{context}: SQLite backup did not complete after {} transient retries \
|
|
(remaining {}, total {})",
|
|
transient_retries, progress.remaining, progress.pagecount
|
|
)));
|
|
}
|
|
transient_retries += 1;
|
|
std::thread::sleep(BACKUP_RETRY_DELAY);
|
|
}
|
|
_ => {
|
|
return Err(AppError::Database(format!(
|
|
"{context}: SQLite returned an unsupported backup step result"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl UntrustedScratch {
|
|
fn empty() -> Result<Self, AppError> {
|
|
let file = NamedTempFile::new().map_err(|error| AppError::IoContext {
|
|
context: "create untrusted restore scratch".to_string(),
|
|
source: error,
|
|
})?;
|
|
let connection =
|
|
Connection::open(file.path()).map_err(|error| AppError::Database(error.to_string()))?;
|
|
let cancellation = Arc::new(AtomicBool::new(false));
|
|
let scratch = Self {
|
|
connection,
|
|
_file: file,
|
|
cancellation,
|
|
};
|
|
scratch.configure_untrusted_execution()?;
|
|
Ok(scratch)
|
|
}
|
|
|
|
fn configure_untrusted_execution(&self) -> Result<(), AppError> {
|
|
self.connection
|
|
.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, false)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.connection
|
|
.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, false)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.connection
|
|
.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.connection
|
|
.set_db_config(DbConfig::SQLITE_DBCONFIG_DQS_DDL, false)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.connection
|
|
.set_db_config(DbConfig::SQLITE_DBCONFIG_DQS_DML, false)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.connection.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0);
|
|
self.connection
|
|
.set_limit(Limit::SQLITE_LIMIT_LENGTH, MAX_SQL_VALUE_BYTES);
|
|
self.connection
|
|
.set_limit(Limit::SQLITE_LIMIT_SQL_LENGTH, MAX_SQL_IMPORT_BYTES as i32);
|
|
self.connection
|
|
.set_limit(Limit::SQLITE_LIMIT_VDBE_OP, 1_000_000);
|
|
self.connection
|
|
.set_limit(Limit::SQLITE_LIMIT_TRIGGER_DEPTH, 0);
|
|
self.connection
|
|
.execute_batch(&format!(
|
|
"PRAGMA trusted_schema = OFF;
|
|
PRAGMA foreign_keys = OFF;
|
|
PRAGMA max_page_count = {};",
|
|
max_page_count()
|
|
))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
|
|
let steps = Arc::new(AtomicU64::new(0));
|
|
let cancellation = Arc::clone(&self.cancellation);
|
|
let max_steps = max_vm_steps();
|
|
self.connection.progress_handler(
|
|
PROGRESS_GRANULARITY as i32,
|
|
Some(move || {
|
|
cancellation.load(Ordering::Relaxed)
|
|
|| steps.fetch_add(PROGRESS_GRANULARITY, Ordering::Relaxed) >= max_steps
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn from_sql(sql: &str) -> Result<Self, AppError> {
|
|
if sql.len() as u64 > MAX_SQL_IMPORT_BYTES {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"SQL import exceeds {MAX_SQL_IMPORT_BYTES} bytes"
|
|
)));
|
|
}
|
|
let scratch = Self::empty()?;
|
|
scratch.connection.authorizer(Some(import_authorizer));
|
|
let result = scratch.connection.execute_batch(sql);
|
|
scratch.connection.authorizer(
|
|
None::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
|
|
);
|
|
result.map_err(|error| {
|
|
AppError::InvalidInput(format!("execute untrusted SQL import: {error}"))
|
|
})?;
|
|
scratch.finish_input()
|
|
}
|
|
|
|
fn from_binary(path: &Path) -> Result<Self, AppError> {
|
|
let owned_source = snapshot_binary_restore_file(path, MAX_BINARY_RESTORE_BYTES)?;
|
|
let source = Connection::open_with_flags(
|
|
owned_source.path(),
|
|
OpenFlags::SQLITE_OPEN_READ_ONLY
|
|
| OpenFlags::SQLITE_OPEN_NOFOLLOW
|
|
| OpenFlags::SQLITE_OPEN_PRIVATE_CACHE,
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let mut scratch = Self::empty()?;
|
|
{
|
|
let backup = Backup::new(&source, &mut scratch.connection)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
run_backup_to_completion(&backup, "clone binary restore into private scratch")?;
|
|
}
|
|
drop(source);
|
|
drop(owned_source);
|
|
scratch.finish_input()
|
|
}
|
|
|
|
fn finish_input(self) -> Result<Self, AppError> {
|
|
self.enforce_scratch_size()?;
|
|
self.constrain_scratch_growth()?;
|
|
let version = Database::get_user_version(&self.connection)?;
|
|
if version > SCHEMA_VERSION {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"restore schema version {version} is newer than supported {SCHEMA_VERSION}"
|
|
)));
|
|
}
|
|
// Gate obsolete backups before schema inspection, sanitizing DDL, or
|
|
// migration dispatch. LocalUpgrade never enters this scratch path and
|
|
// retains the complete historical in-place migration chain.
|
|
super::migration_source::require_supported_untrusted_restore_version(version)?;
|
|
self.drop_untrusted_executable_objects()?;
|
|
self.connection
|
|
.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.connection
|
|
.execute_batch("PRAGMA foreign_keys = OFF;")
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
// Source recognition is deliberately first. In particular, never
|
|
// create current tables in this connection: doing so would turn a
|
|
// missing source table into an apparently valid empty table.
|
|
Database::validate_untrusted_migration_source(&self.connection)?;
|
|
Database::apply_schema_migrations_on_conn(
|
|
&self.connection,
|
|
MigrationRunContext::UntrustedRestore,
|
|
)?;
|
|
self.connection
|
|
.execute_batch("PRAGMA foreign_keys = ON;")
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
self.enforce_scratch_size()?;
|
|
// Keep the progress/cancellation handler installed while fixed-column
|
|
// rows are decoded and copied into the canonical stage. The source is
|
|
// still untrusted during those SELECTs; dropping the handler here would
|
|
// leave the data-transfer half of restore outside the VM budget.
|
|
Ok(self)
|
|
}
|
|
|
|
/// SQLite's Backup API adopts the source page size but keeps the
|
|
/// destination's page-count limit as a raw number. A 64 KiB source would
|
|
/// therefore turn the nominal 2 GiB scratch ceiling into a 32 GiB growth
|
|
/// allowance unless the limit is rebound after cloning.
|
|
fn constrain_scratch_growth(&self) -> Result<(), AppError> {
|
|
let page_size: u64 = self
|
|
.connection
|
|
.query_row("PRAGMA page_size", [], |row| row.get(0))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if page_size == 0 {
|
|
return Err(AppError::InvalidInput(
|
|
"restore scratch reported a zero page size".to_string(),
|
|
));
|
|
}
|
|
let byte_bounded_pages = MAX_SCRATCH_BYTES / page_size;
|
|
let requested = max_page_count().min(byte_bounded_pages);
|
|
if requested == 0 {
|
|
return Err(AppError::InvalidInput(
|
|
"restore scratch page size exceeds the byte budget".to_string(),
|
|
));
|
|
}
|
|
let applied: u64 = self
|
|
.connection
|
|
.query_row(&format!("PRAGMA max_page_count = {requested}"), [], |row| {
|
|
row.get(0)
|
|
})
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if applied > requested {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"restore scratch already exceeds its {requested}-page growth budget"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn drop_untrusted_executable_objects(&self) -> Result<(), AppError> {
|
|
for schema in ["sqlite_schema", "sqlite_temp_schema"] {
|
|
let mut stmt = self
|
|
.connection
|
|
.prepare(&format!(
|
|
"SELECT type, name FROM {schema}
|
|
WHERE type IN ('trigger', 'view', 'index') AND sql IS NOT NULL
|
|
ORDER BY CASE type WHEN 'trigger' THEN 0 WHEN 'view' THEN 1 ELSE 2 END, name"
|
|
))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let objects = stmt
|
|
.query_map([], |row| {
|
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
})
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
drop(stmt);
|
|
for (kind, name) in objects {
|
|
let keyword = match kind.as_str() {
|
|
"trigger" => "TRIGGER",
|
|
"view" => "VIEW",
|
|
"index" => "INDEX",
|
|
_ => {
|
|
return Err(AppError::Database(format!(
|
|
"unsupported scratch object type '{kind}'"
|
|
)))
|
|
}
|
|
};
|
|
let escaped = name.replace('"', "\"\"");
|
|
self.connection
|
|
.execute(&format!("DROP {keyword} IF EXISTS \"{escaped}\""), [])
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn enforce_scratch_size(&self) -> Result<(), AppError> {
|
|
let page_count: u64 = self
|
|
.connection
|
|
.query_row("PRAGMA page_count", [], |row| row.get(0))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let page_size: u64 = self
|
|
.connection
|
|
.query_row("PRAGMA page_size", [], |row| row.get(0))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let logical_size = page_count.saturating_mul(page_size);
|
|
let file_size = self
|
|
._file
|
|
.as_file()
|
|
.metadata()
|
|
.map_err(|error| AppError::io(self._file.path(), error))?
|
|
.len();
|
|
if logical_size > MAX_SCRATCH_BYTES || file_size > MAX_SCRATCH_BYTES {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"restore scratch exceeds {MAX_SCRATCH_BYTES} bytes"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn quoted_columns(spec: &RestoreTableSpec) -> String {
|
|
spec.columns
|
|
.iter()
|
|
.map(|column| format!("\"{}\"", column.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn validate_storage(
|
|
table: &str,
|
|
column: &RestoreColumnSpec,
|
|
value: &Value,
|
|
) -> Result<(), AppError> {
|
|
let valid = match value {
|
|
Value::Null => column.nullable,
|
|
Value::Text(_) => column.storage == StorageKind::Text,
|
|
Value::Integer(_) => column.storage == StorageKind::Integer,
|
|
Value::Real(_) => column.storage == StorageKind::Real,
|
|
Value::Blob(_) => false,
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::InvalidInput(format!(
|
|
"restore row has invalid storage class for {table}.{}",
|
|
column.name
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn validate_integer_domain(
|
|
table: &str,
|
|
column: &RestoreColumnSpec,
|
|
value: &Value,
|
|
) -> Result<(), AppError> {
|
|
let Value::Integer(value) = value else {
|
|
return Ok(());
|
|
};
|
|
let valid = match column.integer_domain {
|
|
IntegerDomain::Unrestricted => true,
|
|
IntegerDomain::Boolean => matches!(*value, 0 | 1),
|
|
IntegerDomain::NonNegative => *value >= 0,
|
|
IntegerDomain::SortIndex => {
|
|
(0..i64::MAX).contains(value) && usize::try_from(*value).is_ok()
|
|
}
|
|
IntegerDomain::Unsigned8 => (0..=u8::MAX as i64).contains(value),
|
|
IntegerDomain::Unsigned16 => (0..=u16::MAX as i64).contains(value),
|
|
IntegerDomain::NonNegativeI32 => (0..=i32::MAX as i64).contains(value),
|
|
IntegerDomain::Unsigned32 => (0..=u32::MAX as i64).contains(value),
|
|
IntegerDomain::InputTokenSemantics => (0..=2).contains(value),
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::InvalidInput(format!(
|
|
"restore row has out-of-domain integer {value} at {table}.{} ({:?})",
|
|
column.name, column.integer_domain
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn validate_real_domain(
|
|
table: &str,
|
|
column: &RestoreColumnSpec,
|
|
value: &Value,
|
|
) -> Result<(), AppError> {
|
|
let Value::Real(value) = value else {
|
|
return Ok(());
|
|
};
|
|
let valid = match column.real_domain {
|
|
RealDomain::NotReal => false,
|
|
RealDomain::FiniteUnitInterval => value.is_finite() && (0.0..=1.0).contains(value),
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::InvalidInput(format!(
|
|
"restore row has out-of-domain real {value} at {table}.{} ({:?})",
|
|
column.name, column.real_domain
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn text_value<'a>(table: &str, column: &str, value: &'a Value) -> Result<&'a str, AppError> {
|
|
match value {
|
|
Value::Text(value) => Ok(value),
|
|
_ => Err(AppError::InvalidInput(format!(
|
|
"restore row requires text at {table}.{column}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn validate_json_text(table: &str, column: &str, value: &Value) -> Result<(), AppError> {
|
|
let text = text_value(table, column, value)?;
|
|
serde_json::from_str::<serde_json::Value>(text)
|
|
.map(|_| ())
|
|
.map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"restore row has invalid JSON at {table}.{column}: {error}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn validate_non_negative_decimal(table: &str, column: &str, value: &Value) -> Result<(), AppError> {
|
|
let value = text_value(table, column, value)?;
|
|
let parsed = value.parse::<rust_decimal::Decimal>().map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"restore row has invalid decimal at {table}.{column}: {error}"
|
|
))
|
|
})?;
|
|
if parsed < rust_decimal::Decimal::ZERO {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"restore row has negative decimal at {table}.{column}: {value}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), AppError> {
|
|
if values.len() != spec.columns.len() {
|
|
return Err(AppError::Database(format!(
|
|
"restore decoder width mismatch for '{}'",
|
|
spec.name
|
|
)));
|
|
}
|
|
for (column, value) in spec.columns.iter().zip(values) {
|
|
validate_storage(spec.name, column, value)?;
|
|
validate_integer_domain(spec.name, column, value)?;
|
|
validate_real_domain(spec.name, column, value)?;
|
|
}
|
|
match spec.validator {
|
|
RestoreRowValidator::OpaqueStorage => {}
|
|
RestoreRowValidator::Provider => {
|
|
let id = text_value(spec.name, "id", &values[0])?;
|
|
let app_type = text_value(spec.name, "app_type", &values[1])?;
|
|
let settings = text_value(spec.name, "settings_config", &values[3])?;
|
|
let meta = text_value(spec.name, "meta", &values[11])?;
|
|
crate::database::dao::providers::validate_provider_storage_json(
|
|
app_type, id, settings, meta,
|
|
)
|
|
.map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"restore provider row '{app_type}/{id}' is not decodable: {error}"
|
|
))
|
|
})?;
|
|
crate::database::validate_cost_multiplier(text_value(
|
|
spec.name,
|
|
"cost_multiplier",
|
|
&values[14],
|
|
)?)?;
|
|
for index in [15_usize, 16] {
|
|
if !matches!(values[index], Value::Null) {
|
|
validate_non_negative_decimal(
|
|
spec.name,
|
|
spec.columns[index].name,
|
|
&values[index],
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
RestoreRowValidator::Mcp => {
|
|
validate_json_text(spec.name, "server_config", &values[2])?;
|
|
serde_json::from_str::<Vec<String>>(text_value(spec.name, "tags", &values[6])?)
|
|
.map(|_| ())
|
|
.map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"restore row has invalid MCP tags at {}.tags: {error}",
|
|
spec.name
|
|
))
|
|
})?;
|
|
}
|
|
RestoreRowValidator::Profile => {
|
|
serde_json::from_str::<crate::services::profile::ProfilePayload>(text_value(
|
|
spec.name, "payload", &values[2],
|
|
)?)
|
|
.map(|_| ())
|
|
.map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"restore row has invalid ProfilePayload at {}.payload: {error}",
|
|
spec.name
|
|
))
|
|
})?;
|
|
}
|
|
RestoreRowValidator::ProxyConfig => {
|
|
crate::database::validate_cost_multiplier(text_value(
|
|
spec.name,
|
|
"default_cost_multiplier",
|
|
&values[16],
|
|
)?)?;
|
|
crate::database::validate_pricing_source(text_value(
|
|
spec.name,
|
|
"pricing_model_source",
|
|
&values[17],
|
|
)?)?;
|
|
}
|
|
RestoreRowValidator::NonNegativeDecimalColumns(indices) => {
|
|
for index in indices {
|
|
validate_non_negative_decimal(
|
|
spec.name,
|
|
spec.columns[*index].name,
|
|
&values[*index],
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_device_local_setting_key(key: &str) -> bool {
|
|
DEVICE_LOCAL_SETTING_KEYS.contains(&key)
|
|
}
|
|
|
|
fn copy_fixed_table(
|
|
source: &Connection,
|
|
target: &Connection,
|
|
spec: &RestoreTableSpec,
|
|
clear_target: bool,
|
|
) -> Result<(), AppError> {
|
|
if clear_target {
|
|
target
|
|
.execute(&format!("DELETE FROM \"{}\"", spec.name), [])
|
|
.map_err(|error| {
|
|
AppError::Database(format!("clear canonical table '{}': {error}", spec.name))
|
|
})?;
|
|
}
|
|
let columns = quoted_columns(spec);
|
|
let select = format!("SELECT {columns} FROM \"{}\"", spec.name);
|
|
let placeholders = (1..=spec.columns.len())
|
|
.map(|index| format!("?{index}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let insert = format!(
|
|
"INSERT INTO \"{}\" ({columns}) VALUES ({placeholders})",
|
|
spec.name
|
|
);
|
|
let mut statement = source.prepare(&select).map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"restore source is missing a fixed column in '{}': {error}",
|
|
spec.name
|
|
))
|
|
})?;
|
|
let mut rows = statement
|
|
.query([])
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
while let Some(row) = rows
|
|
.next()
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
{
|
|
let values = (0..spec.columns.len())
|
|
.map(|index| row.get::<_, Value>(index))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| AppError::InvalidInput(error.to_string()))?;
|
|
if spec.name == "settings"
|
|
&& values
|
|
.first()
|
|
.and_then(|value| match value {
|
|
Value::Text(key) => Some(key.as_str()),
|
|
_ => None,
|
|
})
|
|
.is_some_and(is_device_local_setting_key)
|
|
{
|
|
continue;
|
|
}
|
|
validate_restore_row(spec, &values)?;
|
|
target
|
|
.execute(&insert, rusqlite::params_from_iter(values.iter()))
|
|
.map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"canonical insert into '{}' failed: {error}",
|
|
spec.name
|
|
))
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_live_device_settings(source: &Connection, target: &Connection) -> Result<(), AppError> {
|
|
for key in DEVICE_LOCAL_SETTING_KEYS {
|
|
let value = source
|
|
.query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| {
|
|
row.get::<_, Option<String>>(0)
|
|
})
|
|
.optional()
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
.flatten();
|
|
if let Some(value) = value {
|
|
target
|
|
.execute(
|
|
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
|
rusqlite::params![key, value],
|
|
)
|
|
.map_err(|error| {
|
|
AppError::Database(format!(
|
|
"copy device-local setting at restore boundary: {error}"
|
|
))
|
|
})?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_restore_policy_topology() -> Result<(), AppError> {
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
for spec in RESTORE_TABLE_SPECS {
|
|
if !seen.insert(spec.name) {
|
|
return Err(AppError::Database(format!(
|
|
"duplicate restore policy for '{}'",
|
|
spec.name
|
|
)));
|
|
}
|
|
for column in spec.columns {
|
|
if column.storage != StorageKind::Integer
|
|
&& column.integer_domain != IntegerDomain::Unrestricted
|
|
{
|
|
return Err(AppError::Database(format!(
|
|
"non-integer restore column '{}.{}' declares an integer domain",
|
|
spec.name, column.name
|
|
)));
|
|
}
|
|
if column.storage == StorageKind::Real && column.real_domain == RealDomain::NotReal {
|
|
return Err(AppError::Database(format!(
|
|
"real restore column '{}.{}' has no finite domain",
|
|
spec.name, column.name
|
|
)));
|
|
}
|
|
if column.storage != StorageKind::Real && column.real_domain != RealDomain::NotReal {
|
|
return Err(AppError::Database(format!(
|
|
"non-real restore column '{}.{}' declares a real domain",
|
|
spec.name, column.name
|
|
)));
|
|
}
|
|
}
|
|
for parent in spec.parents {
|
|
if !seen.contains(parent) {
|
|
return Err(AppError::Database(format!(
|
|
"restore policy '{}' precedes parent '{}'",
|
|
spec.name, parent
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn canonical_user_tables(
|
|
conn: &Connection,
|
|
) -> Result<std::collections::BTreeSet<String>, AppError> {
|
|
let mut statement = conn
|
|
.prepare(
|
|
"SELECT name FROM sqlite_schema
|
|
WHERE type = 'table'
|
|
ORDER BY name",
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let tables = statement
|
|
.query_map([], |row| row.get::<_, String>(0))
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
Ok(tables
|
|
.into_iter()
|
|
.filter(|name| !super::is_sqlite_internal_table_name(name))
|
|
.collect())
|
|
}
|
|
|
|
fn assert_restore_policy_coverage(
|
|
canonical_tables: &std::collections::BTreeSet<String>,
|
|
policy_tables: &std::collections::BTreeSet<String>,
|
|
) -> Result<(), AppError> {
|
|
if canonical_tables == policy_tables {
|
|
Ok(())
|
|
} else {
|
|
let missing_policy = canonical_tables
|
|
.difference(policy_tables)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let stale_policy = policy_tables
|
|
.difference(canonical_tables)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
Err(AppError::Database(format!(
|
|
"canonical user tables and restore policy manifest differ; \
|
|
missing policy={missing_policy:?}, stale policy={stale_policy:?}"
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn validate_stage_rows(conn: &Connection) -> Result<(), AppError> {
|
|
for spec in RESTORE_TABLE_SPECS {
|
|
let columns = quoted_columns(spec);
|
|
let mut statement = conn
|
|
.prepare(&format!("SELECT {columns} FROM \"{}\"", spec.name))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let mut rows = statement
|
|
.query([])
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
while let Some(row) = rows
|
|
.next()
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
{
|
|
let values = (0..spec.columns.len())
|
|
.map(|index| row.get::<_, Value>(index))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|error| AppError::InvalidInput(error.to_string()))?;
|
|
validate_restore_row(spec, &values)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_canonical_behaviors(conn: &Connection) -> Result<(), AppError> {
|
|
let transaction = conn
|
|
.unchecked_transaction()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
|
|
let endpoint_max: i64 = transaction
|
|
.query_row(
|
|
"SELECT COALESCE(MAX(id), 0) FROM provider_endpoints",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let (probe_upper, probe_lower, probe_app) = loop {
|
|
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
|
let upper = format!("__restore_A{suffix}");
|
|
let lower = format!("__restore_a{suffix}");
|
|
let app = format!("__restore_probe_{suffix}");
|
|
let existing: i64 = transaction
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM providers
|
|
WHERE app_type = ?1 AND id IN (?2, ?3)",
|
|
rusqlite::params![app, upper, lower],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if existing == 0 {
|
|
break (upper, lower, app);
|
|
}
|
|
};
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES (?1, ?2, 'probe', '{}', '{}')",
|
|
rusqlite::params![probe_upper, probe_app],
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES (?1, ?2, 'probe', '{}', '{}')",
|
|
rusqlite::params![probe_lower, probe_app],
|
|
)
|
|
.map_err(|error| {
|
|
AppError::Database(format!(
|
|
"canonical provider key is not BINARY case-sensitive: {error}"
|
|
))
|
|
})?;
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at, last_used)
|
|
VALUES (?1, ?2, 'https://probe.invalid', NULL, NULL)",
|
|
rusqlite::params![probe_upper, probe_app],
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let endpoint_id = transaction.last_insert_rowid();
|
|
if endpoint_id <= endpoint_max {
|
|
return Err(AppError::Database(
|
|
"provider_endpoints AUTOINCREMENT did not advance past explicit restored IDs"
|
|
.to_string(),
|
|
));
|
|
}
|
|
if transaction
|
|
.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES (?1, ?2, 'replacement', '{}', '{}')",
|
|
rusqlite::params![probe_upper, probe_app],
|
|
)
|
|
.is_ok()
|
|
{
|
|
return Err(AppError::Database(
|
|
"canonical provider duplicate policy is not ABORT".to_string(),
|
|
));
|
|
}
|
|
let endpoint_count: i64 = transaction
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM provider_endpoints
|
|
WHERE provider_id = ?1 AND app_type = ?2",
|
|
rusqlite::params![probe_upper, probe_app],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if endpoint_count != 1 {
|
|
return Err(AppError::Database(
|
|
"duplicate provider insert replaced or cascaded endpoint rows".to_string(),
|
|
));
|
|
}
|
|
|
|
let stream_max: i64 = transaction
|
|
.query_row(
|
|
"SELECT COALESCE(MAX(id), 0) FROM stream_check_logs",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO stream_check_logs
|
|
(provider_id, provider_name, app_type, status, success, message, tested_at)
|
|
VALUES ('__probe', 'probe', '__probe', 'ok', 1, 'ok', 1)",
|
|
[],
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if transaction.last_insert_rowid() <= stream_max {
|
|
return Err(AppError::Database(
|
|
"stream_check_logs AUTOINCREMENT did not advance past explicit restored IDs"
|
|
.to_string(),
|
|
));
|
|
}
|
|
transaction
|
|
.rollback()
|
|
.map_err(|error| AppError::Database(error.to_string()))
|
|
}
|
|
|
|
fn validate_canonical_stage(stage: &CanonicalStage) -> Result<(), AppError> {
|
|
let conn = stage.connection();
|
|
if Database::get_user_version(conn)? != SCHEMA_VERSION {
|
|
return Err(AppError::Database(
|
|
"canonical stage has an unexpected schema version".to_string(),
|
|
));
|
|
}
|
|
assert_restore_policy_topology()?;
|
|
let policy_tables = RESTORE_TABLE_SPECS
|
|
.iter()
|
|
.map(|spec| spec.name.to_string())
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
assert_restore_policy_coverage(&canonical_user_tables(conn)?, &policy_tables)?;
|
|
validate_stage_rows(conn)?;
|
|
let integrity: String = conn
|
|
.query_row("PRAGMA integrity_check(1)", [], |row| row.get(0))
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if integrity != "ok" {
|
|
return Err(AppError::Database(format!(
|
|
"canonical integrity_check failed: {integrity}"
|
|
)));
|
|
}
|
|
let mut foreign_keys = conn
|
|
.prepare("PRAGMA foreign_key_check")
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if foreign_keys
|
|
.query([])
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
.next()
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
.is_some()
|
|
{
|
|
return Err(AppError::Database(
|
|
"canonical foreign_key_check failed".to_string(),
|
|
));
|
|
}
|
|
drop(foreign_keys);
|
|
validate_canonical_behaviors(conn)
|
|
}
|
|
|
|
/// A database backup entry for the UI
|
|
#[derive(Debug, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BackupEntry {
|
|
pub filename: String,
|
|
pub size_bytes: u64,
|
|
pub created_at: String, // ISO 8601
|
|
}
|
|
|
|
impl Database {
|
|
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串,完整导出)
|
|
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
|
let snapshot = self.snapshot_to_memory()?;
|
|
Self::dump_sql(&snapshot, DEVICE_LOCAL_TABLES)
|
|
}
|
|
|
|
/// Export SQL for sync (WebDAV), skipping local-only tables' data
|
|
pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
|
|
let snapshot = self.snapshot_to_memory()?;
|
|
Self::dump_sql(&snapshot, SYNC_SKIP_TABLES)
|
|
}
|
|
|
|
/// 导出为 SQLite 兼容的 SQL 文本
|
|
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
|
let dump = self.export_sql_string()?;
|
|
|
|
if let Some(parent) = target_path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
}
|
|
|
|
crate::config::atomic_write(target_path, dump.as_bytes())
|
|
}
|
|
|
|
/// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
|
|
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
|
|
let bytes = read_restore_file(source_path, MAX_SQL_IMPORT_BYTES)?;
|
|
let sql = std::str::from_utf8(&bytes).map_err(|error| {
|
|
AppError::InvalidInput(format!(
|
|
"SQL restore source is not UTF-8 ({}): {error}",
|
|
source_path.display()
|
|
))
|
|
})?;
|
|
self.import_sql_string(sql)
|
|
}
|
|
|
|
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
|
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
|
self.import_sql_string_inner(sql_raw, RestoreFlavor::UserRestore)
|
|
}
|
|
|
|
/// Import SQL generated for sync, then restore local-only tables from the
|
|
/// current device snapshot before replacing the main database.
|
|
pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
|
|
self.import_sql_string_inner(sql_raw, RestoreFlavor::Sync)
|
|
}
|
|
|
|
fn import_sql_string_inner(
|
|
&self,
|
|
sql_raw: &str,
|
|
flavor: RestoreFlavor,
|
|
) -> Result<String, AppError> {
|
|
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
|
Self::validate_cc_switch_sql_export(sql_content)?;
|
|
let scratch = UntrustedScratch::from_sql(sql_content)?;
|
|
let stage = Self::build_canonical_stage(&scratch)?;
|
|
let backup_path = self.publish_canonical_stage(stage, flavor)?;
|
|
let backup_id = backup_path
|
|
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
|
.unwrap_or_default();
|
|
Ok(backup_id)
|
|
}
|
|
|
|
/// 创建内存快照以避免长时间持有数据库锁
|
|
pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
let mut snapshot =
|
|
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
{
|
|
let backup =
|
|
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
|
|
run_backup_to_completion(&backup, "snapshot live database into memory")?;
|
|
}
|
|
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn build_canonical_stage(scratch: &UntrustedScratch) -> Result<CanonicalStage, AppError> {
|
|
let mut stage = Self::current_canonical_stage()?;
|
|
stage
|
|
.connection()
|
|
.execute_batch("PRAGMA foreign_keys = ON;")
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
let transaction = stage
|
|
.connection_mut()
|
|
.transaction()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
|
|
// The factory may create canonical seed rows. Clear every non-seed
|
|
// table child-first so incoming rows are always inserted with plain
|
|
// INSERT into a clean target.
|
|
for spec in RESTORE_TABLE_SPECS.iter().rev() {
|
|
if spec.policy != RestorePolicy::SeedCanonical {
|
|
transaction
|
|
.execute(&format!("DELETE FROM \"{}\"", spec.name), [])
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
}
|
|
}
|
|
for spec in RESTORE_TABLE_SPECS {
|
|
if spec.policy == RestorePolicy::PortableIncoming {
|
|
copy_fixed_table(&scratch.connection, &transaction, spec, false)?;
|
|
}
|
|
}
|
|
transaction
|
|
.commit()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
validate_canonical_stage(&stage)?;
|
|
Ok(stage)
|
|
}
|
|
|
|
/// Freeze live writes before the safety snapshot and keep the same
|
|
/// connection guard through publication. This is the sole publish
|
|
/// boundary: it consumes only a schema-factory-owned CanonicalStage, and no
|
|
/// helper accepts a raw Connection as a publishable source.
|
|
fn publish_canonical_stage(
|
|
&self,
|
|
mut stage: CanonicalStage,
|
|
flavor: RestoreFlavor,
|
|
) -> Result<Option<PathBuf>, AppError> {
|
|
let mut main_conn = lock_conn!(self.conn);
|
|
let safety_backup = Self::backup_database_file_on_locked_connection(&main_conn)?;
|
|
#[cfg(test)]
|
|
run_after_safety_backup_test_seam();
|
|
validate_canonical_stage(&stage)?;
|
|
{
|
|
let transaction = stage
|
|
.connection_mut()
|
|
.transaction()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
for spec in RESTORE_TABLE_SPECS {
|
|
let preserve = spec.policy == RestorePolicy::PreserveLive
|
|
|| (flavor == RestoreFlavor::Sync
|
|
&& SYNC_LIVE_OVERLAY_TABLES.contains(&spec.name));
|
|
if preserve {
|
|
copy_fixed_table(&main_conn, &transaction, spec, true)?;
|
|
}
|
|
}
|
|
copy_live_device_settings(&main_conn, &transaction)?;
|
|
transaction
|
|
.commit()
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
}
|
|
validate_canonical_stage(&stage)?;
|
|
let backup = Backup::new(stage.connection(), &mut main_conn)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
run_backup_to_completion(&backup, "publish canonical restore stage")?;
|
|
Ok(safety_backup)
|
|
}
|
|
|
|
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
|
|
let trimmed = sql.trim_start();
|
|
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(AppError::localized(
|
|
"backup.sql.invalid_format",
|
|
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
|
|
"Only SQL backups exported by CC Switch are supported.",
|
|
))
|
|
}
|
|
|
|
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
|
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
|
let interval_hours = crate::settings::effective_backup_interval_hours();
|
|
if interval_hours > 0 {
|
|
let backup_dir = get_app_config_dir().join("backups");
|
|
if !backup_dir.exists() {
|
|
self.backup_database_file()?;
|
|
} else {
|
|
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
|
entries
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
|
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
|
.max()
|
|
});
|
|
|
|
let interval_secs = u64::from(interval_hours) * 3600;
|
|
let needs_backup = match latest {
|
|
None => true,
|
|
Some(last_modified) => {
|
|
last_modified.elapsed().unwrap_or_default()
|
|
> std::time::Duration::from_secs(interval_secs)
|
|
}
|
|
};
|
|
|
|
if needs_backup {
|
|
log::info!(
|
|
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
|
);
|
|
self.backup_database_file()?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Periodic maintenance is always enabled, regardless of auto-backup settings.
|
|
let mut reclaimed_rows = 0u64;
|
|
match self.cleanup_old_stream_check_logs(7) {
|
|
Ok(deleted) => {
|
|
reclaimed_rows += deleted;
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Periodic stream_check_logs cleanup failed: {e}");
|
|
}
|
|
}
|
|
match self.rollup_and_prune(30) {
|
|
Ok(deleted) => {
|
|
reclaimed_rows += deleted;
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Periodic rollup_and_prune failed: {e}");
|
|
}
|
|
}
|
|
if reclaimed_rows > 0 {
|
|
let conn = lock_conn!(self.conn);
|
|
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
|
log::warn!("Periodic incremental vacuum failed: {e}");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
|
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::backup_database_file_on_locked_connection(&conn)
|
|
}
|
|
|
|
fn backup_database_file_on_locked_connection(
|
|
conn: &Connection,
|
|
) -> Result<Option<PathBuf>, AppError> {
|
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
|
if !db_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let backup_dir = db_path
|
|
.parent()
|
|
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
|
|
.join("backups");
|
|
|
|
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
|
|
|
let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S"));
|
|
let mut backup_id = base_id.clone();
|
|
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
|
let mut counter = 1;
|
|
while backup_path.exists() {
|
|
backup_id = format!("{base_id}_{counter}");
|
|
backup_path = backup_dir.join(format!("{backup_id}.db"));
|
|
counter += 1;
|
|
}
|
|
|
|
let result = (|| {
|
|
let mut dest_conn =
|
|
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
|
{
|
|
let backup = Backup::new(conn, &mut dest_conn)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
run_backup_to_completion(&backup, "create database safety backup")?;
|
|
}
|
|
Ok(())
|
|
})();
|
|
if let Err(error) = result {
|
|
if let Err(remove_error) = fs::remove_file(&backup_path) {
|
|
if remove_error.kind() != std::io::ErrorKind::NotFound {
|
|
log::warn!(
|
|
"failed to remove incomplete safety backup '{}': {remove_error}",
|
|
backup_path.display()
|
|
);
|
|
}
|
|
}
|
|
return Err(error);
|
|
}
|
|
|
|
let completed =
|
|
open_nofollow(&backup_path).map_err(|error| AppError::io(&backup_path, error))?;
|
|
Self::cleanup_db_backups(&backup_dir, Some(&backup_path))?;
|
|
let still_current =
|
|
open_nofollow(&backup_path).map_err(|error| AppError::io(&backup_path, error))?;
|
|
if !still_current
|
|
.metadata()
|
|
.map_err(|error| AppError::io(&backup_path, error))?
|
|
.file_type()
|
|
.is_file()
|
|
|| !same_open_file_identity(&completed, &still_current)
|
|
.map_err(|error| AppError::io(&backup_path, error))?
|
|
{
|
|
return Err(AppError::InvalidInput(
|
|
"completed safety backup changed during retention cleanup".to_string(),
|
|
));
|
|
}
|
|
Ok(Some(backup_path))
|
|
}
|
|
|
|
/// 清理旧的数据库备份,保留最新的 N 个
|
|
fn cleanup_db_backups(dir: &Path, protected: Option<&Path>) -> Result<(), AppError> {
|
|
let retain = crate::settings::effective_backup_retain_count();
|
|
let entries = match fs::read_dir(dir) {
|
|
Ok(iter) => iter
|
|
.filter_map(|entry| entry.ok())
|
|
.filter(|entry| {
|
|
entry
|
|
.path()
|
|
.extension()
|
|
.map(|ext| ext == "db")
|
|
.unwrap_or(false)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
if entries.len() <= retain {
|
|
return Ok(());
|
|
}
|
|
|
|
let remove_count = entries.len().saturating_sub(retain);
|
|
let mut sorted = entries
|
|
.into_iter()
|
|
.filter(|entry| protected.is_none_or(|path| entry.path() != path))
|
|
.collect::<Vec<_>>();
|
|
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
|
|
|
for entry in sorted.into_iter().take(remove_count) {
|
|
if let Err(err) = fs::remove_file(entry.path()) {
|
|
log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 导出数据库为 SQL 文本
|
|
fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result<String, AppError> {
|
|
let mut device_local_secrets = Vec::new();
|
|
let has_settings: bool = conn
|
|
.query_row(
|
|
"SELECT EXISTS(
|
|
SELECT 1 FROM sqlite_schema
|
|
WHERE type = 'table' AND name = 'settings'
|
|
)",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
if has_settings {
|
|
for key in DEVICE_LOCAL_SETTING_KEYS {
|
|
if let Some(value) = conn
|
|
.query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| {
|
|
row.get::<_, Option<String>>(0)
|
|
})
|
|
.optional()
|
|
.map_err(|error| AppError::Database(error.to_string()))?
|
|
.flatten()
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
device_local_secrets.push(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut output = String::new();
|
|
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
let user_version: i64 = conn
|
|
.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
|
.unwrap_or(0);
|
|
|
|
output.push_str(&format!(
|
|
"-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
|
|
));
|
|
output.push_str("PRAGMA foreign_keys=OFF;\n");
|
|
output.push_str(&format!("PRAGMA user_version={user_version};\n"));
|
|
output.push_str("BEGIN TRANSACTION;\n");
|
|
|
|
// 导出 schema
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT type, name, tbl_name, sql
|
|
FROM sqlite_master
|
|
WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
|
|
ORDER BY type='table' DESC, name",
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
let mut tables = Vec::new();
|
|
let mut rows = stmt
|
|
.query([])
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
|
|
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
|
|
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// Skip only the exact internal objects owned by this SQLite build.
|
|
// Prefix matching would misclassify names such as `sqliteX`.
|
|
if super::is_sqlite_internal_table_name(&name) {
|
|
continue;
|
|
}
|
|
|
|
output.push_str(&sql);
|
|
output.push_str(";\n");
|
|
|
|
if obj_type == "table" && !super::is_sqlite_internal_table_name(&name) {
|
|
tables.push(name);
|
|
}
|
|
}
|
|
|
|
// 导出数据
|
|
for table in tables {
|
|
if skip_tables.iter().any(|t| *t == table) {
|
|
continue;
|
|
}
|
|
let columns = Self::get_table_columns(conn, &table)?;
|
|
if columns.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let mut stmt = conn
|
|
.prepare(&format!("SELECT * FROM \"{table}\""))
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
let mut rows = stmt
|
|
.query([])
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
if table == "settings"
|
|
&& row
|
|
.get_ref(0)
|
|
.ok()
|
|
.and_then(|value| match value {
|
|
ValueRef::Text(key) => std::str::from_utf8(key).ok(),
|
|
_ => None,
|
|
})
|
|
.is_some_and(is_device_local_setting_key)
|
|
{
|
|
continue;
|
|
}
|
|
let mut values = Vec::with_capacity(columns.len());
|
|
for idx in 0..columns.len() {
|
|
let value = row
|
|
.get_ref(idx)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
values.push(Self::format_sql_value(value)?);
|
|
}
|
|
|
|
let cols = columns
|
|
.iter()
|
|
.map(|c| format!("\"{c}\""))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
output.push_str(&format!(
|
|
"INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
|
|
values.join(", ")
|
|
));
|
|
}
|
|
}
|
|
|
|
output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
|
|
if DEVICE_LOCAL_SETTING_KEYS
|
|
.iter()
|
|
.any(|key| output.contains(key))
|
|
|| device_local_secrets
|
|
.iter()
|
|
.any(|secret| output.contains(secret))
|
|
{
|
|
return Err(AppError::Database(
|
|
"portable SQL export contained a device-local credential".to_string(),
|
|
));
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn dump_sql_for_migration_test(conn: &Connection) -> Result<String, AppError> {
|
|
Self::dump_sql(conn, &[])
|
|
}
|
|
|
|
/// 获取表的列名列表
|
|
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
|
|
let mut stmt = conn
|
|
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
let iter = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
let mut columns = Vec::new();
|
|
for col in iter {
|
|
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
|
|
}
|
|
Ok(columns)
|
|
}
|
|
|
|
/// 格式化 SQL 值
|
|
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
|
|
match value {
|
|
ValueRef::Null => Ok("NULL".to_string()),
|
|
ValueRef::Integer(i) => Ok(i.to_string()),
|
|
ValueRef::Real(f) => Ok(f.to_string()),
|
|
ValueRef::Text(t) => {
|
|
let text = std::str::from_utf8(t)
|
|
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
|
|
let escaped = text.replace('\'', "''");
|
|
Ok(format!("'{escaped}'"))
|
|
}
|
|
ValueRef::Blob(bytes) => {
|
|
let mut s = String::from("X'");
|
|
for b in bytes {
|
|
use std::fmt::Write;
|
|
let _ = write!(&mut s, "{b:02X}");
|
|
}
|
|
s.push('\'');
|
|
Ok(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List all database backup files, sorted by creation time (newest first)
|
|
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
|
|
let backup_dir = get_app_config_dir().join("backups");
|
|
if !backup_dir.exists() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
|
|
.map_err(|e| AppError::io(&backup_dir, e))?
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
|
.filter_map(|e| {
|
|
let metadata = e.metadata().ok()?;
|
|
let filename = e.file_name().to_string_lossy().to_string();
|
|
let size_bytes = metadata.len();
|
|
let created_at = metadata
|
|
.modified()
|
|
.ok()
|
|
.map(|t| {
|
|
let dt: chrono::DateTime<Utc> = t.into();
|
|
dt.to_rfc3339()
|
|
})
|
|
.unwrap_or_default();
|
|
Some(BackupEntry {
|
|
filename,
|
|
size_bytes,
|
|
created_at,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Sort by created_at descending (newest first)
|
|
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Restore database from a backup file. Returns the safety backup ID.
|
|
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
|
|
validate_backup_filename(filename)?;
|
|
|
|
let backup_dir = get_app_config_dir().join("backups");
|
|
validate_backup_directory(&backup_dir)?;
|
|
let backup_path = backup_dir.join(filename);
|
|
|
|
// Build a canonical data-only stage before touching the live database.
|
|
let scratch = UntrustedScratch::from_binary(&backup_path)?;
|
|
let stage = Self::build_canonical_stage(&scratch)?;
|
|
|
|
// The live connection guard is acquired before the safety snapshot and
|
|
// remains held through publish, closing the write-loss window.
|
|
let safety_backup = self.publish_canonical_stage(stage, RestoreFlavor::UserRestore)?;
|
|
let safety_id = safety_backup
|
|
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
|
.unwrap_or_default();
|
|
|
|
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
|
|
Ok(safety_id)
|
|
}
|
|
|
|
/// Rename a backup file. Returns the new filename.
|
|
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
|
|
validate_backup_filename(old_filename)?;
|
|
|
|
// Clean new name
|
|
let trimmed = new_name.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"New name cannot be empty".to_string(),
|
|
));
|
|
}
|
|
|
|
// Length limit (without .db suffix)
|
|
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
|
|
if name_part.len() > 100 {
|
|
return Err(AppError::InvalidInput(
|
|
"Name too long (max 100 characters)".to_string(),
|
|
));
|
|
}
|
|
|
|
// Prevent path traversal in new name
|
|
if name_part.contains('\0') || name_part.contains(':') {
|
|
return Err(AppError::InvalidInput(
|
|
"Invalid characters in new name".to_string(),
|
|
));
|
|
}
|
|
|
|
let new_filename = format!("{name_part}.db");
|
|
validate_backup_filename(&new_filename)?;
|
|
|
|
let backup_dir = get_app_config_dir().join("backups");
|
|
validate_backup_directory(&backup_dir)?;
|
|
let old_path = backup_dir.join(old_filename);
|
|
let new_path = backup_dir.join(&new_filename);
|
|
|
|
if !old_path.exists() {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Backup file not found: {old_filename}"
|
|
)));
|
|
}
|
|
|
|
if new_path.exists() {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"A backup named '{new_filename}' already exists"
|
|
)));
|
|
}
|
|
|
|
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
|
|
log::info!("Renamed backup: {old_filename} -> {new_filename}");
|
|
Ok(new_filename)
|
|
}
|
|
|
|
/// Delete a backup file permanently.
|
|
pub fn delete_backup(filename: &str) -> Result<(), AppError> {
|
|
validate_backup_filename(filename)?;
|
|
let backup_dir = get_app_config_dir().join("backups");
|
|
validate_backup_directory(&backup_dir)?;
|
|
let backup_path = backup_dir.join(filename);
|
|
if !backup_path.exists() {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Backup file not found: {filename}"
|
|
)));
|
|
}
|
|
|
|
fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;
|
|
log::info!("Deleted backup: {filename}");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
assert_restore_policy_coverage, validate_canonical_behaviors, validate_regular_file,
|
|
validate_stage_rows, Database, MigrationRunContext, RestoreFlavor, RestorePolicy,
|
|
RestoreRowValidator, StorageKind, UntrustedScratch, MAX_BINARY_RESTORE_BYTES,
|
|
MAX_SCRATCH_BYTES, MAX_SQL_IMPORT_BYTES, RESTORE_TABLE_SPECS, SCHEMA_VERSION,
|
|
TEST_MAX_BACKUP_TRANSIENT_RETRIES, TEST_MAX_PAGE_COUNT, TEST_MAX_VM_STEPS,
|
|
};
|
|
use crate::error::AppError;
|
|
use crate::settings::{get_settings, update_settings, AppSettings};
|
|
use rusqlite::backup::Backup;
|
|
use rusqlite::Connection;
|
|
use serial_test::serial;
|
|
use sha2::{Digest, Sha256};
|
|
use std::ffi::OsString;
|
|
use std::fs::{self, File};
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum RestoreEntryPoint {
|
|
Sql,
|
|
Binary,
|
|
}
|
|
|
|
struct TestHomeGuard(Option<OsString>);
|
|
|
|
impl TestHomeGuard {
|
|
fn set(path: &std::path::Path) -> Self {
|
|
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
|
|
std::env::set_var("CC_SWITCH_TEST_HOME", path);
|
|
Self(previous)
|
|
}
|
|
}
|
|
|
|
impl Drop for TestHomeGuard {
|
|
fn drop(&mut self) {
|
|
match self.0.take() {
|
|
Some(previous) => std::env::set_var("CC_SWITCH_TEST_HOME", previous),
|
|
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RestoreLimitGuard {
|
|
previous_vm_steps: Option<u64>,
|
|
previous_page_count: Option<u64>,
|
|
}
|
|
|
|
impl RestoreLimitGuard {
|
|
fn set(vm_steps: Option<u64>, page_count: Option<u64>) -> Self {
|
|
let previous_vm_steps = TEST_MAX_VM_STEPS.with(|current| current.replace(vm_steps));
|
|
let previous_page_count =
|
|
TEST_MAX_PAGE_COUNT.with(|current| current.replace(page_count));
|
|
Self {
|
|
previous_vm_steps,
|
|
previous_page_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for RestoreLimitGuard {
|
|
fn drop(&mut self) {
|
|
TEST_MAX_VM_STEPS.with(|current| current.set(self.previous_vm_steps));
|
|
TEST_MAX_PAGE_COUNT.with(|current| current.set(self.previous_page_count));
|
|
}
|
|
}
|
|
|
|
struct BackupRetryGuard(Option<u32>);
|
|
|
|
impl BackupRetryGuard {
|
|
fn set(limit: u32) -> Self {
|
|
Self(TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(|current| current.replace(Some(limit))))
|
|
}
|
|
}
|
|
|
|
impl Drop for BackupRetryGuard {
|
|
fn drop(&mut self) {
|
|
TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(|current| current.set(self.0));
|
|
}
|
|
}
|
|
|
|
struct AppSettingsGuard(AppSettings);
|
|
|
|
impl AppSettingsGuard {
|
|
fn replace(settings: AppSettings) -> Result<Self, AppError> {
|
|
let previous = get_settings();
|
|
update_settings(settings)?;
|
|
Ok(Self(previous))
|
|
}
|
|
}
|
|
|
|
impl Drop for AppSettingsGuard {
|
|
fn drop(&mut self) {
|
|
if let Err(error) = update_settings(self.0.clone()) {
|
|
log::error!("failed to restore test settings: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn restore_policy_snapshot() -> serde_json::Value {
|
|
let full_specs = RESTORE_TABLE_SPECS
|
|
.iter()
|
|
.map(|spec| {
|
|
let policy = match spec.policy {
|
|
RestorePolicy::PortableIncoming => "portable_incoming",
|
|
RestorePolicy::PreserveLive => "preserve_live",
|
|
RestorePolicy::RebuildRuntime => "rebuild_runtime",
|
|
RestorePolicy::SeedCanonical => "seed_canonical",
|
|
};
|
|
let validator = match spec.validator {
|
|
RestoreRowValidator::OpaqueStorage => {
|
|
serde_json::json!("opaque_storage")
|
|
}
|
|
RestoreRowValidator::Provider => serde_json::json!("provider"),
|
|
RestoreRowValidator::Mcp => serde_json::json!("mcp"),
|
|
RestoreRowValidator::Profile => serde_json::json!("profile"),
|
|
RestoreRowValidator::ProxyConfig => serde_json::json!("proxy_config"),
|
|
RestoreRowValidator::NonNegativeDecimalColumns(indices) => {
|
|
serde_json::json!({"nonNegativeDecimalColumns": indices})
|
|
}
|
|
};
|
|
serde_json::json!({
|
|
"name": spec.name,
|
|
"policy": policy,
|
|
"columns": spec.columns.iter().map(|column| {
|
|
let storage = match column.storage {
|
|
StorageKind::Text => "text",
|
|
StorageKind::Integer => "integer",
|
|
StorageKind::Real => "real",
|
|
};
|
|
let integer_domain = match column.integer_domain {
|
|
super::IntegerDomain::Unrestricted => "unrestricted",
|
|
super::IntegerDomain::Boolean => "boolean",
|
|
super::IntegerDomain::NonNegative => "non_negative",
|
|
super::IntegerDomain::SortIndex => "sort_index",
|
|
super::IntegerDomain::Unsigned8 => "unsigned_8",
|
|
super::IntegerDomain::Unsigned16 => "unsigned_16",
|
|
super::IntegerDomain::NonNegativeI32 => "non_negative_i32",
|
|
super::IntegerDomain::Unsigned32 => "unsigned_32",
|
|
super::IntegerDomain::InputTokenSemantics => {
|
|
"input_token_semantics"
|
|
}
|
|
};
|
|
let real_domain = match column.real_domain {
|
|
super::RealDomain::NotReal => "not_real",
|
|
super::RealDomain::FiniteUnitInterval => "finite_unit_interval",
|
|
};
|
|
serde_json::json!([
|
|
column.name,
|
|
storage,
|
|
column.nullable,
|
|
integer_domain,
|
|
real_domain
|
|
])
|
|
}).collect::<Vec<_>>(),
|
|
"validator": validator,
|
|
"parents": spec.parents,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let digest = Sha256::digest(
|
|
serde_json::to_vec(&full_specs).expect("serialize restore policy authority"),
|
|
);
|
|
let tables = full_specs
|
|
.iter()
|
|
.map(|spec| {
|
|
serde_json::json!({
|
|
"name": spec["name"],
|
|
"policy": spec["policy"],
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
serde_json::json!({
|
|
"manifestVersion": 1,
|
|
"codeAuthority": "src-tauri/src/database/backup.rs",
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"limits": {
|
|
"sqlImportBytes": MAX_SQL_IMPORT_BYTES,
|
|
"binaryRestoreBytes": MAX_BINARY_RESTORE_BYTES,
|
|
"scratchBytes": MAX_SCRATCH_BYTES,
|
|
},
|
|
"specSha256": format!("{digest:x}"),
|
|
"tables": tables,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn restore_policy_snapshot_is_exhaustive_and_detects_missing_table_fixture(
|
|
) -> Result<(), AppError> {
|
|
let expected: serde_json::Value = serde_json::from_str(include_str!(
|
|
"../../../tests/fixtures/pi/restore-policy-v1.json"
|
|
))
|
|
.expect("parse restore policy snapshot");
|
|
assert_eq!(restore_policy_snapshot(), expected);
|
|
|
|
let canonical = Database::current_canonical_stage()?;
|
|
let canonical_tables = super::canonical_user_tables(canonical.connection())?;
|
|
let policy_tables = RESTORE_TABLE_SPECS
|
|
.iter()
|
|
.map(|spec| spec.name.to_string())
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
assert_restore_policy_coverage(&canonical_tables, &policy_tables)?;
|
|
|
|
let mut negative_fixture = canonical_tables;
|
|
negative_fixture.insert("future_table_without_policy".to_string());
|
|
let error = assert_restore_policy_coverage(&negative_fixture, &policy_tables)
|
|
.expect_err("a new table without a policy must fail the scanner");
|
|
assert!(error.to_string().contains("future_table_without_policy"));
|
|
Ok(())
|
|
}
|
|
|
|
fn canonical_restore_source() -> Result<Connection, AppError> {
|
|
let source = Database::memory()?;
|
|
{
|
|
let conn = crate::database::lock_conn!(source.conn);
|
|
conn.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES ('remote-provider', 'pi', 'Remote Provider', '{}', '{}')",
|
|
[],
|
|
)?;
|
|
}
|
|
source.snapshot_to_memory()
|
|
}
|
|
|
|
fn exact_version_source_with_pi_provider(version: i32) -> Result<Connection, AppError> {
|
|
let source = crate::database::migration_source::exact_migration_source_for_test(version)?;
|
|
let provider_id = format!("migration-v{version}");
|
|
let settings_config =
|
|
crate::database::migration_source::pinned_pi_provider_settings_for_test(version);
|
|
if version == 1 {
|
|
source.execute(
|
|
"INSERT INTO providers (
|
|
id, app_type, name, settings_config, website_url, category,
|
|
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
|
) VALUES (
|
|
?1, 'pi', ?2, ?3, ?4, 'custom', 1700000001, 1,
|
|
'Pi migration sentinel', 'pi', '#13579b', '{}', 1
|
|
)",
|
|
rusqlite::params![
|
|
provider_id,
|
|
format!("Migration v{version}"),
|
|
settings_config,
|
|
format!("https://pi-v{version}.example")
|
|
],
|
|
)?;
|
|
source.execute_batch(
|
|
"INSERT INTO proxy_config (
|
|
id, proxy_enabled, listen_address, listen_port, enable_logging,
|
|
max_retries, streaming_first_byte_timeout,
|
|
streaming_idle_timeout, non_streaming_timeout
|
|
) VALUES (1, 1, '10.20.30.40', 23456, 0, 9, 61, 122, 603);
|
|
INSERT INTO circuit_breaker_config (
|
|
id, failure_threshold, success_threshold, timeout_seconds,
|
|
error_rate_threshold, min_requests
|
|
) VALUES (1, 7, 4, 73, 0.375, 21);
|
|
INSERT INTO settings (key, value) VALUES
|
|
('proxy_takeover_claude', 'true'),
|
|
('auto_failover_enabled_claude', 'false'),
|
|
('proxy_takeover_codex', 'false'),
|
|
('auto_failover_enabled_codex', 'true'),
|
|
('proxy_takeover_gemini', 'true'),
|
|
('auto_failover_enabled_gemini', 'true');",
|
|
)?;
|
|
} else {
|
|
source.execute(
|
|
"INSERT INTO providers (
|
|
id, app_type, name, settings_config, website_url, category,
|
|
created_at, sort_index, notes, icon, icon_color, meta,
|
|
is_current, in_failover_queue, cost_multiplier,
|
|
limit_daily_usd, limit_monthly_usd, provider_type
|
|
) VALUES (
|
|
?1, 'pi', ?2, ?3, ?4, 'custom', 1700000001, 1,
|
|
'Pi migration sentinel', 'pi', '#13579b', '{}',
|
|
1, 0, '1.25', '42.50', '420.50', 'pi-native'
|
|
)",
|
|
rusqlite::params![
|
|
provider_id,
|
|
format!("Migration v{version}"),
|
|
settings_config,
|
|
format!("https://pi-v{version}.example")
|
|
],
|
|
)?;
|
|
}
|
|
if version == SCHEMA_VERSION {
|
|
source.execute(
|
|
"INSERT INTO provider_endpoints (
|
|
id, provider_id, app_type, url, added_at, last_used
|
|
) VALUES (?1, ?2, 'pi', ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
10_000 + version,
|
|
provider_id,
|
|
format!("https://pi-endpoint-v{version}.example/v1"),
|
|
1_700_000_000 + version,
|
|
1_700_100_000 + version
|
|
],
|
|
)?;
|
|
} else {
|
|
source.execute(
|
|
"INSERT INTO provider_endpoints (
|
|
id, provider_id, app_type, url, added_at
|
|
) VALUES (?1, ?2, 'pi', ?3, ?4)",
|
|
rusqlite::params![
|
|
10_000 + version,
|
|
provider_id,
|
|
format!("https://pi-endpoint-v{version}.example/v1"),
|
|
1_700_000_000 + version
|
|
],
|
|
)?;
|
|
}
|
|
Ok(source)
|
|
}
|
|
|
|
fn actual_v16_duplicate_endpoint_source() -> Result<Connection, AppError> {
|
|
let source = canonical_restore_source()?;
|
|
source.execute_batch(
|
|
"PRAGMA foreign_keys = OFF;
|
|
DROP TABLE provider_endpoints;
|
|
CREATE TABLE provider_endpoints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
added_at INTEGER,
|
|
FOREIGN KEY (provider_id, app_type)
|
|
REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO provider_endpoints
|
|
(id, provider_id, app_type, url, added_at)
|
|
VALUES
|
|
(1601, 'remote-provider', 'pi', 'https://duplicate-v16.invalid', 20),
|
|
(1602, 'remote-provider', 'pi', 'https://duplicate-v16.invalid', 10);
|
|
DROP TABLE pi_provider_projections;
|
|
DROP TABLE skill_deployments;
|
|
ALTER TABLE skills DROP COLUMN enabled_pi;
|
|
PRAGMA user_version = 16;",
|
|
)?;
|
|
Ok(source)
|
|
}
|
|
|
|
fn weak_ledger_source() -> Result<Connection, AppError> {
|
|
let source = canonical_restore_source()?;
|
|
source.execute_batch(
|
|
"PRAGMA foreign_keys = OFF;
|
|
DROP TABLE pi_provider_projections;
|
|
DROP TABLE skill_deployments;
|
|
CREATE TABLE pi_provider_projections (
|
|
provider_id TEXT,
|
|
provider_key TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
CREATE UNIQUE INDEX remote_projection_alternate
|
|
ON pi_provider_projections(updated_at);
|
|
CREATE TABLE skill_deployments (
|
|
app_type TEXT,
|
|
skill_id TEXT,
|
|
destination TEXT,
|
|
destination_key TEXT,
|
|
method TEXT,
|
|
source_identity TEXT,
|
|
deployed_digest TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
CREATE UNIQUE INDEX remote_skill_alternate
|
|
ON skill_deployments(skill_id);
|
|
INSERT INTO pi_provider_projections
|
|
(provider_id, provider_key, created_at, updated_at)
|
|
VALUES ('remote-provider', 'remote-key', 10, 10);
|
|
INSERT INTO skill_deployments (
|
|
app_type, skill_id, destination, destination_key, method,
|
|
source_identity, created_at, updated_at
|
|
) VALUES (
|
|
'codex', 'remote-skill', '/remote', '/remote', 'move',
|
|
'remote-source', 10, 10
|
|
);
|
|
PRAGMA foreign_keys = ON;",
|
|
)?;
|
|
Ok(source)
|
|
}
|
|
|
|
fn weak_endpoint_source() -> Result<Connection, AppError> {
|
|
let source = canonical_restore_source()?;
|
|
source.execute_batch(
|
|
"PRAGMA foreign_keys = OFF;
|
|
DROP TABLE provider_endpoints;
|
|
CREATE TABLE provider_endpoints (
|
|
id INTEGER PRIMARY KEY,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
added_at INTEGER,
|
|
last_used INTEGER
|
|
);
|
|
INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at)
|
|
VALUES ('remote-provider', 'pi', 'https://weak.test', 1);
|
|
PRAGMA foreign_keys = ON;",
|
|
)?;
|
|
Ok(source)
|
|
}
|
|
|
|
fn seed_local_ledgers(target: &Database) -> Result<(), AppError> {
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
conn.execute(
|
|
"INSERT INTO pi_provider_projections
|
|
(provider_id, provider_key, created_at, updated_at)
|
|
VALUES ('local-provider', 'local-key', 20, 20)",
|
|
[],
|
|
)?;
|
|
conn.execute(
|
|
"INSERT INTO skill_deployments (
|
|
app_type, skill_id, destination, destination_key, method,
|
|
source_identity, created_at, updated_at
|
|
) VALUES (
|
|
'pi', 'local-skill', '/local', '/local', 'copy',
|
|
'local-source', 20, 20
|
|
)",
|
|
[],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn run_restore_entry(
|
|
target: &Database,
|
|
source: &Connection,
|
|
entry_point: RestoreEntryPoint,
|
|
filename: &str,
|
|
) -> Result<String, AppError> {
|
|
match entry_point {
|
|
RestoreEntryPoint::Sql => {
|
|
let sql = Database::dump_sql(source, &[])?;
|
|
target.import_sql_string(&sql)
|
|
}
|
|
RestoreEntryPoint::Binary => {
|
|
let backup_dir = crate::config::get_app_config_dir().join("backups");
|
|
std::fs::create_dir_all(&backup_dir)
|
|
.map_err(|error| AppError::io(&backup_dir, error))?;
|
|
let backup_path = backup_dir.join(filename);
|
|
let mut destination = Connection::open(&backup_path)?;
|
|
{
|
|
let backup = Backup::new(source, &mut destination)?;
|
|
super::run_backup_to_completion(&backup, "prepare binary restore fixture")?;
|
|
}
|
|
drop(destination);
|
|
target.restore_from_backup(filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn logical_snapshot(target: &Database) -> Result<String, AppError> {
|
|
let snapshot = target.snapshot_to_memory()?;
|
|
let dump = Database::dump_sql(&snapshot, &[])?;
|
|
Ok(dump
|
|
.lines()
|
|
.filter(|line| !line.starts_with("-- 生成时间:"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"))
|
|
}
|
|
|
|
const WEAK_PROVIDERS_NO_KEY: &str = "CREATE TABLE providers (
|
|
id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
settings_config TEXT NOT NULL,
|
|
website_url TEXT,
|
|
category TEXT,
|
|
created_at INTEGER,
|
|
sort_index INTEGER,
|
|
notes TEXT,
|
|
icon TEXT,
|
|
icon_color TEXT,
|
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
|
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
limit_daily_usd TEXT,
|
|
limit_monthly_usd TEXT,
|
|
provider_type TEXT
|
|
)";
|
|
|
|
const HOSTILE_PROVIDERS_NOCASE_REPLACE: &str = "CREATE TABLE providers (
|
|
id TEXT COLLATE NOCASE NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
settings_config TEXT NOT NULL,
|
|
website_url TEXT,
|
|
category TEXT,
|
|
created_at INTEGER,
|
|
sort_index INTEGER,
|
|
notes TEXT,
|
|
icon TEXT,
|
|
icon_color TEXT,
|
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
|
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
limit_daily_usd TEXT,
|
|
limit_monthly_usd TEXT,
|
|
provider_type TEXT,
|
|
PRIMARY KEY (id, app_type) ON CONFLICT REPLACE
|
|
)";
|
|
|
|
const WEAK_PROVIDER_ENDPOINTS: &str = "CREATE TABLE provider_endpoints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
added_at INTEGER,
|
|
last_used INTEGER
|
|
)";
|
|
|
|
fn replace_schema_object(
|
|
exported: &mut String,
|
|
source: &Connection,
|
|
object_type: &str,
|
|
name: &str,
|
|
replacement: &str,
|
|
) -> Result<(), AppError> {
|
|
let original: String = source.query_row(
|
|
"SELECT sql FROM sqlite_schema WHERE type = ?1 AND name = ?2",
|
|
rusqlite::params![object_type, name],
|
|
|row| row.get(0),
|
|
)?;
|
|
let needle = format!("{original};");
|
|
if !exported.contains(&needle) {
|
|
return Err(AppError::Database(format!(
|
|
"test export did not contain schema object {object_type}/{name}"
|
|
)));
|
|
}
|
|
*exported = exported.replacen(&needle, &format!("{replacement};"), 1);
|
|
Ok(())
|
|
}
|
|
|
|
fn rewritten_source(
|
|
provider_ddl: Option<&str>,
|
|
endpoint_ddl: Option<&str>,
|
|
) -> Result<Connection, AppError> {
|
|
let base = canonical_restore_source()?;
|
|
let mut exported = Database::dump_sql(&base, &[])?;
|
|
if let Some(definition) = provider_ddl {
|
|
replace_schema_object(&mut exported, &base, "table", "providers", definition)?;
|
|
}
|
|
if let Some(definition) = endpoint_ddl {
|
|
replace_schema_object(
|
|
&mut exported,
|
|
&base,
|
|
"table",
|
|
"provider_endpoints",
|
|
definition,
|
|
)?;
|
|
}
|
|
let source = Connection::open_in_memory()?;
|
|
source.execute_batch(&exported)?;
|
|
Ok(source)
|
|
}
|
|
|
|
fn seed_live_restore_state(target: &Database) -> Result<(), AppError> {
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
conn.execute_batch(
|
|
"INSERT INTO providers
|
|
(id, app_type, name, settings_config, meta, in_failover_queue)
|
|
VALUES ('live-provider', 'pi', 'Live Provider', '{}', '{}', 1);
|
|
INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at, last_used)
|
|
VALUES ('live-provider', 'pi', 'https://live.invalid', NULL, NULL);
|
|
INSERT INTO pi_provider_projections
|
|
(provider_id, provider_key, created_at, updated_at)
|
|
VALUES ('live-provider', 'live-key', 20, 20);
|
|
INSERT INTO skill_deployments (
|
|
app_type, skill_id, destination, destination_key, method,
|
|
source_identity, created_at, updated_at
|
|
) VALUES (
|
|
'pi', 'live-skill', '/live', '/live', 'copy',
|
|
'live-source', 20, 20
|
|
);",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum InvalidRestoreCase {
|
|
ProviderJson,
|
|
McpTagsShape,
|
|
ProfilePayloadShape,
|
|
StorageClass,
|
|
NegativeSortIndex,
|
|
MaxSortIndex,
|
|
BooleanDomain,
|
|
Unsigned8Domain,
|
|
Unsigned16Domain,
|
|
NonNegativeI32Domain,
|
|
Unsigned32Domain,
|
|
InputTokenSemanticsDomain,
|
|
NegativeCircuitThreshold,
|
|
OutOfRangeCircuitThreshold,
|
|
NonFiniteCircuitThreshold,
|
|
NegativeProviderMultiplier,
|
|
NegativeProviderMetaLimit,
|
|
InvalidProviderPricingSource,
|
|
NegativeProxyMultiplier,
|
|
InvalidProxyPricingSource,
|
|
NegativeModelPrice,
|
|
DuplicateProvider,
|
|
DuplicateEndpoint,
|
|
ForeignKeyOrphan,
|
|
FutureVersion,
|
|
}
|
|
|
|
impl InvalidRestoreCase {
|
|
const ALL: [Self; 25] = [
|
|
Self::ProviderJson,
|
|
Self::McpTagsShape,
|
|
Self::ProfilePayloadShape,
|
|
Self::StorageClass,
|
|
Self::NegativeSortIndex,
|
|
Self::MaxSortIndex,
|
|
Self::BooleanDomain,
|
|
Self::Unsigned8Domain,
|
|
Self::Unsigned16Domain,
|
|
Self::NonNegativeI32Domain,
|
|
Self::Unsigned32Domain,
|
|
Self::InputTokenSemanticsDomain,
|
|
Self::NegativeCircuitThreshold,
|
|
Self::OutOfRangeCircuitThreshold,
|
|
Self::NonFiniteCircuitThreshold,
|
|
Self::NegativeProviderMultiplier,
|
|
Self::NegativeProviderMetaLimit,
|
|
Self::InvalidProviderPricingSource,
|
|
Self::NegativeProxyMultiplier,
|
|
Self::InvalidProxyPricingSource,
|
|
Self::NegativeModelPrice,
|
|
Self::DuplicateProvider,
|
|
Self::DuplicateEndpoint,
|
|
Self::ForeignKeyOrphan,
|
|
Self::FutureVersion,
|
|
];
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::ProviderJson => "provider-json",
|
|
Self::McpTagsShape => "mcp-tags-shape",
|
|
Self::ProfilePayloadShape => "profile-payload-shape",
|
|
Self::StorageClass => "storage-class",
|
|
Self::NegativeSortIndex => "negative-sort-index",
|
|
Self::MaxSortIndex => "max-sort-index",
|
|
Self::BooleanDomain => "boolean-domain",
|
|
Self::Unsigned8Domain => "unsigned-8-domain",
|
|
Self::Unsigned16Domain => "unsigned-16-domain",
|
|
Self::NonNegativeI32Domain => "non-negative-i32-domain",
|
|
Self::Unsigned32Domain => "unsigned-32-domain",
|
|
Self::InputTokenSemanticsDomain => "input-token-semantics-domain",
|
|
Self::NegativeCircuitThreshold => "negative-circuit-threshold",
|
|
Self::OutOfRangeCircuitThreshold => "out-of-range-circuit-threshold",
|
|
Self::NonFiniteCircuitThreshold => "non-finite-circuit-threshold",
|
|
Self::NegativeProviderMultiplier => "negative-provider-multiplier",
|
|
Self::NegativeProviderMetaLimit => "negative-provider-meta-limit",
|
|
Self::InvalidProviderPricingSource => "invalid-provider-pricing-source",
|
|
Self::NegativeProxyMultiplier => "negative-proxy-multiplier",
|
|
Self::InvalidProxyPricingSource => "invalid-proxy-pricing-source",
|
|
Self::NegativeModelPrice => "negative-model-price",
|
|
Self::DuplicateProvider => "duplicate-provider",
|
|
Self::DuplicateEndpoint => "duplicate-endpoint",
|
|
Self::ForeignKeyOrphan => "foreign-key-orphan",
|
|
Self::FutureVersion => "future-version",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn invalid_restore_source(case: InvalidRestoreCase) -> Result<Connection, AppError> {
|
|
let source = match case {
|
|
InvalidRestoreCase::DuplicateProvider => {
|
|
rewritten_source(Some(WEAK_PROVIDERS_NO_KEY), None)?
|
|
}
|
|
InvalidRestoreCase::DuplicateEndpoint => {
|
|
rewritten_source(None, Some(WEAK_PROVIDER_ENDPOINTS))?
|
|
}
|
|
_ => canonical_restore_source()?,
|
|
};
|
|
match case {
|
|
InvalidRestoreCase::ProviderJson => {
|
|
source.execute(
|
|
"UPDATE providers SET settings_config = '{' WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::McpTagsShape => {
|
|
source.execute(
|
|
"INSERT INTO mcp_servers (id, name, server_config, tags)
|
|
VALUES ('invalid-tags', 'Invalid Tags', '{}', '{}')",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::ProfilePayloadShape => {
|
|
source.execute(
|
|
"INSERT INTO profiles (id, name, payload)
|
|
VALUES (
|
|
'invalid-profile',
|
|
'Invalid Profile',
|
|
'{\"providers\":[1]}'
|
|
)",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::StorageClass => {
|
|
source.execute(
|
|
"UPDATE providers SET created_at = X'00' WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NegativeSortIndex => {
|
|
source.execute(
|
|
"UPDATE providers SET sort_index = -1 WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::MaxSortIndex => {
|
|
source.execute(
|
|
"UPDATE providers SET sort_index = 9223372036854775807
|
|
WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::BooleanDomain => {
|
|
source.execute(
|
|
"UPDATE providers SET in_failover_queue = 2
|
|
WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::Unsigned8Domain => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET max_retries = 256
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::Unsigned16Domain => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET listen_port = 65536
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NonNegativeI32Domain => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET streaming_first_byte_timeout = 2147483648
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::Unsigned32Domain => {
|
|
source.execute(
|
|
"INSERT INTO proxy_request_logs (
|
|
request_id, provider_id, app_type, model, input_tokens,
|
|
latency_ms, status_code, created_at
|
|
) VALUES (
|
|
'domain-u32', 'remote-provider', 'pi', 'model',
|
|
4294967296, 1, 200, 1
|
|
)",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::InputTokenSemanticsDomain => {
|
|
source.execute(
|
|
"INSERT INTO proxy_request_logs (
|
|
request_id, provider_id, app_type, model,
|
|
input_token_semantics, latency_ms, status_code, created_at
|
|
) VALUES (
|
|
'domain-token-semantics', 'remote-provider', 'pi', 'model',
|
|
3, 1, 200, 1
|
|
)",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NegativeCircuitThreshold => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET circuit_error_rate_threshold = -0.5
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::OutOfRangeCircuitThreshold => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET circuit_error_rate_threshold = 6.5
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NonFiniteCircuitThreshold => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET circuit_error_rate_threshold = ?1
|
|
WHERE app_type = 'claude'",
|
|
[f64::INFINITY],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NegativeProviderMultiplier => {
|
|
source.execute(
|
|
"UPDATE providers SET cost_multiplier = '-1'
|
|
WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NegativeProviderMetaLimit => {
|
|
source.execute(
|
|
"UPDATE providers SET meta = '{\"limitDailyUsd\":\"-1\"}'
|
|
WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::InvalidProviderPricingSource => {
|
|
source.execute(
|
|
"UPDATE providers SET meta = '{\"pricingModelSource\":\"invalid\"}'
|
|
WHERE id = 'remote-provider'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NegativeProxyMultiplier => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET default_cost_multiplier = '-1'
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::InvalidProxyPricingSource => {
|
|
source.execute(
|
|
"UPDATE proxy_config SET pricing_model_source = 'invalid'
|
|
WHERE app_type = 'claude'",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::NegativeModelPrice => {
|
|
source.execute(
|
|
"INSERT INTO model_pricing (
|
|
model_id, display_name, input_cost_per_million,
|
|
output_cost_per_million, cache_read_cost_per_million,
|
|
cache_creation_cost_per_million
|
|
) VALUES (
|
|
'negative-price', 'Negative Price', '-1', '0', '0', '0'
|
|
)",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::DuplicateProvider => {
|
|
source.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES ('remote-provider', 'pi', 'Duplicate', '{}', '{}')",
|
|
[],
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::DuplicateEndpoint => {
|
|
source.execute_batch(
|
|
"INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at, last_used)
|
|
VALUES
|
|
('remote-provider', 'pi', 'https://duplicate.invalid', NULL, NULL),
|
|
('remote-provider', 'pi', 'https://duplicate.invalid', 1, 2);",
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::ForeignKeyOrphan => {
|
|
source.execute_batch(
|
|
"PRAGMA foreign_keys = OFF;
|
|
INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at, last_used)
|
|
VALUES ('missing', 'pi', 'https://orphan.invalid', NULL, NULL);
|
|
PRAGMA foreign_keys = ON;",
|
|
)?;
|
|
}
|
|
InvalidRestoreCase::FutureVersion => {
|
|
Database::set_user_version(&source, SCHEMA_VERSION + 1)?;
|
|
}
|
|
}
|
|
Ok(source)
|
|
}
|
|
|
|
fn assert_weak_ledgers_are_rebuilt(
|
|
entry_point: RestoreEntryPoint,
|
|
filename: &str,
|
|
) -> Result<(), AppError> {
|
|
let source = weak_ledger_source()?;
|
|
let target = Database::memory()?;
|
|
seed_local_ledgers(&target)?;
|
|
run_restore_entry(&target, &source, entry_point, filename)?;
|
|
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
validate_stage_rows(&conn)?;
|
|
validate_canonical_behaviors(&conn)?;
|
|
let counts: (i64, i64, i64, i64, i64) = conn.query_row(
|
|
"SELECT
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'remote-provider' AND app_type = 'pi'),
|
|
(SELECT COUNT(*) FROM pi_provider_projections
|
|
WHERE provider_id = 'local-provider' AND provider_key = 'local-key'),
|
|
(SELECT COUNT(*) FROM pi_provider_projections
|
|
WHERE provider_id = 'remote-provider' OR provider_key = 'remote-key'),
|
|
(SELECT COUNT(*) FROM skill_deployments
|
|
WHERE skill_id = 'local-skill' AND destination_key = '/local'),
|
|
(SELECT COUNT(*) FROM skill_deployments
|
|
WHERE skill_id = 'remote-skill')",
|
|
[],
|
|
|row| {
|
|
Ok((
|
|
row.get(0)?,
|
|
row.get(1)?,
|
|
row.get(2)?,
|
|
row.get(3)?,
|
|
row.get(4)?,
|
|
))
|
|
},
|
|
)?;
|
|
assert_eq!(counts, (1, 1, 0, 1, 0));
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_invalid_live_copy_is_atomic(
|
|
entry_point: RestoreEntryPoint,
|
|
filename: &str,
|
|
) -> Result<(), AppError> {
|
|
let source = canonical_restore_source()?;
|
|
let target = Database::memory()?;
|
|
{
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
conn.execute_batch(
|
|
"PRAGMA ignore_check_constraints = ON;
|
|
INSERT INTO skill_deployments (
|
|
app_type, skill_id, destination, destination_key, method,
|
|
source_identity, created_at, updated_at
|
|
) VALUES (
|
|
'codex', 'invalid-local', '/invalid', '/invalid', 'move',
|
|
'invalid-source', 1, 1
|
|
);
|
|
PRAGMA ignore_check_constraints = OFF;",
|
|
)?;
|
|
}
|
|
let before = logical_snapshot(&target)?;
|
|
assert!(run_restore_entry(&target, &source, entry_point, filename).is_err());
|
|
assert_eq!(logical_snapshot(&target)?, before);
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_weak_endpoint_is_canonicalized(
|
|
entry_point: RestoreEntryPoint,
|
|
filename: &str,
|
|
) -> Result<(), AppError> {
|
|
let source = weak_endpoint_source()?;
|
|
let target = Database::memory()?;
|
|
seed_local_ledgers(&target)?;
|
|
run_restore_entry(&target, &source, entry_point, filename)?;
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
validate_stage_rows(&conn)?;
|
|
validate_canonical_behaviors(&conn)?;
|
|
let endpoint_count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM provider_endpoints
|
|
WHERE provider_id = 'remote-provider'
|
|
AND app_type = 'pi'
|
|
AND url = 'https://weak.test'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
assert_eq!(endpoint_count, 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn sql_and_binary_restore_share_canonical_prepublication_contracts() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create canonical restore test home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
|
|
assert_weak_ledgers_are_rebuilt(RestoreEntryPoint::Sql, "unused-ledger.db")?;
|
|
assert_invalid_live_copy_is_atomic(RestoreEntryPoint::Sql, "unused-invalid.db")?;
|
|
assert_weak_endpoint_is_canonicalized(RestoreEntryPoint::Sql, "unused-endpoint.db")?;
|
|
|
|
assert_weak_ledgers_are_rebuilt(RestoreEntryPoint::Binary, "ledger.db")?;
|
|
assert_invalid_live_copy_is_atomic(RestoreEntryPoint::Binary, "invalid-copy.db")?;
|
|
assert_weak_endpoint_is_canonicalized(RestoreEntryPoint::Binary, "weak-endpoint.db")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn sql_and_binary_restore_preserve_incremental_auto_vacuum() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create auto-vacuum restore home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let target = Database::init()?;
|
|
let source = canonical_restore_source()?;
|
|
let auto_vacuum = || -> Result<i64, AppError> {
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
conn.query_row("PRAGMA auto_vacuum", [], |row| row.get(0))
|
|
.map_err(|error| AppError::Database(error.to_string()))
|
|
};
|
|
|
|
assert_eq!(auto_vacuum()?, 2);
|
|
run_restore_entry(
|
|
&target,
|
|
&source,
|
|
RestoreEntryPoint::Sql,
|
|
"unused-auto-vacuum.db",
|
|
)?;
|
|
assert_eq!(auto_vacuum()?, 2);
|
|
run_restore_entry(
|
|
&target,
|
|
&source,
|
|
RestoreEntryPoint::Binary,
|
|
"binary-auto-vacuum.db",
|
|
)?;
|
|
assert_eq!(auto_vacuum()?, 2);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn public_restore_entries_discard_hostile_schema_and_publish_only_canonical_objects(
|
|
) -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create hostile schema restore home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let source = rewritten_source(
|
|
Some(HOSTILE_PROVIDERS_NOCASE_REPLACE),
|
|
Some(WEAK_PROVIDER_ENDPOINTS),
|
|
)?;
|
|
source.execute_batch(
|
|
"CREATE INDEX source_leak_index ON providers(name);
|
|
CREATE VIEW source_leak_view AS SELECT id, app_type FROM providers;
|
|
CREATE TRIGGER source_leak_trigger
|
|
AFTER INSERT ON provider_endpoints
|
|
BEGIN
|
|
UPDATE settings SET value = NEW.url WHERE key = 'source-leak';
|
|
END;
|
|
INSERT INTO provider_endpoints
|
|
(id, provider_id, app_type, url, added_at, last_used)
|
|
VALUES
|
|
(7001, 'remote-provider', 'pi', 'https://weak.invalid', NULL, 8);",
|
|
)?;
|
|
|
|
for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let target = Database::memory()?;
|
|
seed_local_ledgers(&target)?;
|
|
run_restore_entry(
|
|
&target,
|
|
&source,
|
|
entry_point,
|
|
&format!("hostile-schema-{index}.db"),
|
|
)?;
|
|
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
validate_stage_rows(&conn)?;
|
|
validate_canonical_behaviors(&conn)?;
|
|
let leaked_objects: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_schema
|
|
WHERE name IN (
|
|
'source_leak_index',
|
|
'source_leak_view',
|
|
'source_leak_trigger'
|
|
)",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
assert_eq!(leaked_objects, 0, "source executable DDL must not publish");
|
|
|
|
let provider_schema: String = conn.query_row(
|
|
"SELECT sql FROM sqlite_schema
|
|
WHERE type = 'table' AND name = 'providers'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
assert!(!provider_schema.to_ascii_uppercase().contains("NOCASE"));
|
|
assert!(!provider_schema.to_ascii_uppercase().contains("REPLACE"));
|
|
|
|
let endpoint_schema: String = conn.query_row(
|
|
"SELECT sql FROM sqlite_schema
|
|
WHERE type = 'table' AND name = 'provider_endpoints'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
let endpoint_schema = endpoint_schema.to_ascii_uppercase();
|
|
assert!(endpoint_schema.contains("FOREIGN KEY"));
|
|
assert!(endpoint_schema.contains("UNIQUE"));
|
|
let endpoint: (i64, Option<i64>, Option<i64>) = conn.query_row(
|
|
"SELECT id, added_at, last_used FROM provider_endpoints
|
|
WHERE provider_id = 'remote-provider'
|
|
AND app_type = 'pi'
|
|
AND url = 'https://weak.invalid'",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
)?;
|
|
assert_eq!(endpoint, (7001, None, Some(8)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn public_restore_entries_abort_invalid_rows_without_live_or_ledger_mutation(
|
|
) -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create invalid restore matrix home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
|
|
for case in InvalidRestoreCase::ALL {
|
|
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let source = invalid_restore_source(case)?;
|
|
let target = Database::memory()?;
|
|
seed_live_restore_state(&target)?;
|
|
let before = logical_snapshot(&target)?;
|
|
let result = run_restore_entry(
|
|
&target,
|
|
&source,
|
|
entry_point,
|
|
&format!("invalid-{}-{entry_index}.db", case.label()),
|
|
);
|
|
assert!(
|
|
result.is_err(),
|
|
"{} via entry {entry_index} must fail closed",
|
|
case.label()
|
|
);
|
|
assert_eq!(
|
|
logical_snapshot(&target)?,
|
|
before,
|
|
"{} via entry {entry_index} changed live state",
|
|
case.label()
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn public_restore_entries_preserve_nulls_unknown_json_and_explicit_ids() -> Result<(), AppError>
|
|
{
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create lossless restore matrix home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let source = canonical_restore_source()?;
|
|
let settings = r#"{"api":"future-api","futureShape":{"nested":[1,2,3]}}"#;
|
|
let meta = r#"{"futureMeta":{"opaque":true}}"#;
|
|
source.execute(
|
|
"UPDATE providers
|
|
SET settings_config = ?1, meta = ?2
|
|
WHERE id = 'remote-provider' AND app_type = 'pi'",
|
|
rusqlite::params![settings, meta],
|
|
)?;
|
|
source.execute(
|
|
"INSERT INTO provider_endpoints
|
|
(id, provider_id, app_type, url, added_at, last_used)
|
|
VALUES (9001, 'remote-provider', 'pi', 'https://null.invalid', NULL, NULL)",
|
|
[],
|
|
)?;
|
|
source.execute(
|
|
"INSERT INTO provider_endpoints
|
|
(id, provider_id, app_type, url, added_at, last_used)
|
|
VALUES (-9001, 'remote-provider', 'pi', 'https://negative-id.invalid', NULL, NULL)",
|
|
[],
|
|
)?;
|
|
source.execute(
|
|
"INSERT INTO stream_check_logs (
|
|
id, provider_id, provider_name, app_type, status, success, message, tested_at
|
|
) VALUES (
|
|
-9002, 'remote-provider', 'Remote Provider', 'pi', 'ok', 1, 'ok', 1
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let target = Database::memory()?;
|
|
run_restore_entry(
|
|
&target,
|
|
&source,
|
|
entry_point,
|
|
&format!("lossless-{index}.db"),
|
|
)?;
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
let restored: (i64, Option<i64>, Option<i64>, String, String) = conn.query_row(
|
|
"SELECT e.id, e.added_at, e.last_used, p.settings_config, p.meta
|
|
FROM provider_endpoints AS e
|
|
JOIN providers AS p
|
|
ON p.id = e.provider_id AND p.app_type = e.app_type
|
|
WHERE e.url = 'https://null.invalid'",
|
|
[],
|
|
|row| {
|
|
Ok((
|
|
row.get(0)?,
|
|
row.get(1)?,
|
|
row.get(2)?,
|
|
row.get(3)?,
|
|
row.get(4)?,
|
|
))
|
|
},
|
|
)?;
|
|
assert_eq!(
|
|
restored,
|
|
(9001, None, None, settings.to_string(), meta.to_string())
|
|
);
|
|
let negative_endpoint_id: i64 = conn.query_row(
|
|
"SELECT id FROM provider_endpoints
|
|
WHERE url = 'https://negative-id.invalid'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
let negative_stream_id: i64 = conn.query_row(
|
|
"SELECT id FROM stream_check_logs
|
|
WHERE provider_id = 'remote-provider'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
assert_eq!(negative_endpoint_id, -9001);
|
|
assert_eq!(negative_stream_id, -9002);
|
|
conn.execute(
|
|
"INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at, last_used)
|
|
VALUES (
|
|
'remote-provider',
|
|
'pi',
|
|
'https://next-id.invalid',
|
|
NULL,
|
|
NULL
|
|
)",
|
|
[],
|
|
)?;
|
|
assert!(
|
|
conn.last_insert_rowid() > 9001,
|
|
"AUTOINCREMENT must advance without copying sqlite_sequence"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn public_restore_entries_reject_v16_endpoint_repair_without_merging() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create v16 duplicate endpoint restore home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
|
|
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let source = actual_v16_duplicate_endpoint_source()?;
|
|
let target = Database::memory()?;
|
|
seed_live_restore_state(&target)?;
|
|
let before = logical_snapshot(&target)?;
|
|
|
|
let error = run_restore_entry(
|
|
&target,
|
|
&source,
|
|
entry_point,
|
|
&format!("duplicate-v16-{entry_index}.db"),
|
|
)
|
|
.expect_err("an untrusted v16 duplicate endpoint must not be repaired");
|
|
assert!(
|
|
matches!(error, AppError::InvalidInput(_)),
|
|
"migration repair rejection must remain structured: {error:?}"
|
|
);
|
|
assert!(
|
|
error.to_string().contains("migration repair is forbidden"),
|
|
"restore must fail at the untrusted migration boundary: {error}"
|
|
);
|
|
assert_eq!(
|
|
logical_snapshot(&target)?,
|
|
before,
|
|
"failed v16 repair changed live state via entry {entry_index}"
|
|
);
|
|
let duplicate_count: i64 = source.query_row(
|
|
"SELECT COUNT(*) FROM provider_endpoints
|
|
WHERE provider_id = 'remote-provider'
|
|
AND app_type = 'pi'
|
|
AND url = 'https://duplicate-v16.invalid'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
assert_eq!(
|
|
duplicate_count, 2,
|
|
"untrusted migration must not merge the source rows"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn every_supported_user_version_has_a_public_migration_sentinel() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create restore migration matrix home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
|
|
// A legacy v0 label must fail at the N/N-1 gate before migration DDL
|
|
// instead of being interpreted through the local-upgrade path.
|
|
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let oldest = Connection::open_in_memory()?;
|
|
oldest.execute_batch(
|
|
"CREATE TABLE providers (
|
|
id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
settings_config TEXT NOT NULL DEFAULT '{}',
|
|
website_url TEXT,
|
|
PRIMARY KEY (id, app_type)
|
|
);
|
|
INSERT INTO providers (
|
|
id, app_type, name, settings_config, website_url
|
|
) VALUES (
|
|
'actual-v0-provider', 'pi', 'Actual v0 Provider', '{}', NULL
|
|
);
|
|
PRAGMA user_version = 0;",
|
|
)?;
|
|
let target = Database::memory()?;
|
|
let error = run_restore_entry(
|
|
&target,
|
|
&oldest,
|
|
entry_point,
|
|
&format!("actual-v0-{entry_index}.db"),
|
|
)
|
|
.expect_err("untrusted v0 is outside the declared source-spec range");
|
|
assert!(
|
|
matches!(error, AppError::InvalidInput(_))
|
|
&& error.to_string().contains("user_version=0")
|
|
&& error.to_string().contains("备份版本过旧"),
|
|
"v0 must fail at source recognition via entry {entry_index}: {error:?}"
|
|
);
|
|
}
|
|
|
|
// Exercise the real immediately-previous v16 shape. In particular,
|
|
// these columns and device-local tables did not exist yet; relabeling
|
|
// a v17 database cannot prove that the migration supplies them.
|
|
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let previous = canonical_restore_source()?;
|
|
previous.execute_batch(
|
|
"INSERT INTO provider_endpoints
|
|
(provider_id, app_type, url, added_at, last_used)
|
|
VALUES ('remote-provider', 'pi', 'https://v16.invalid', 16, NULL);
|
|
INSERT INTO skills (
|
|
id, name, directory, enabled_codex, enabled_pi,
|
|
installed_at, updated_at
|
|
) VALUES ('actual-v16-skill', 'Actual v16 Skill', '/v16', 1, 0, 16, 16);
|
|
PRAGMA foreign_keys = OFF;
|
|
DROP TABLE pi_provider_projections;
|
|
DROP TABLE skill_deployments;
|
|
ALTER TABLE provider_endpoints DROP COLUMN last_used;
|
|
ALTER TABLE skills DROP COLUMN enabled_pi;
|
|
PRAGMA user_version = 16;",
|
|
)?;
|
|
let target = Database::memory()?;
|
|
run_restore_entry(
|
|
&target,
|
|
&previous,
|
|
entry_point,
|
|
&format!("actual-v16-{entry_index}.db"),
|
|
)?;
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
let sentinel: (Option<i64>, i64, i64, i64) = conn.query_row(
|
|
"SELECT
|
|
(SELECT last_used FROM provider_endpoints
|
|
WHERE provider_id = 'remote-provider'
|
|
AND app_type = 'pi'
|
|
AND url = 'https://v16.invalid'),
|
|
(SELECT enabled_pi FROM skills WHERE id = 'actual-v16-skill'),
|
|
(SELECT COUNT(*) FROM pi_provider_projections),
|
|
(SELECT COUNT(*) FROM skill_deployments)",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
|
)?;
|
|
assert_eq!(sentinel, (None, 0, 0, 0));
|
|
}
|
|
|
|
// Both supported versions are materialized from their exact source specs.
|
|
// Each public entry must preserve a real Pi provider and its endpoint;
|
|
// this cannot pass by stamping a v17 database with a v16 label.
|
|
for version in (SCHEMA_VERSION - 1)..=SCHEMA_VERSION {
|
|
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let source = exact_version_source_with_pi_provider(version)?;
|
|
let target = Database::memory()?;
|
|
run_restore_entry(
|
|
&target,
|
|
&source,
|
|
entry_point,
|
|
&format!("exact-migration-v{version}-{entry_index}.db"),
|
|
)?;
|
|
let conn = crate::database::lock_conn!(target.conn);
|
|
let restored: (String, String, String, Option<i64>) = conn.query_row(
|
|
"SELECT p.settings_config, p.cost_multiplier, e.url, e.last_used
|
|
FROM providers AS p
|
|
JOIN provider_endpoints AS e
|
|
ON e.provider_id = p.id AND e.app_type = p.app_type
|
|
WHERE p.id = ?1 AND p.app_type = 'pi'",
|
|
[format!("migration-v{version}")],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
|
)?;
|
|
assert!(
|
|
restored.0.contains("\"id\":\"fractional\"")
|
|
&& restored
|
|
.0
|
|
.contains(&format!("\"migrationVersion\":{version}")),
|
|
"Pi settings payload was not preserved for v{version}"
|
|
);
|
|
assert_eq!(restored.1, "1.25", "provider migration sentinel v{version}");
|
|
assert_eq!(
|
|
restored.2,
|
|
format!("https://pi-endpoint-v{version}.example/v1"),
|
|
"endpoint migration sentinel v{version}"
|
|
);
|
|
assert_eq!(
|
|
restored.3,
|
|
(version == SCHEMA_VERSION).then_some(1_700_100_000 + i64::from(version)),
|
|
"endpoint last_used migration sentinel v{version}"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> {
|
|
// `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样,
|
|
// 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。
|
|
let cases: [(&str, &str); 2] = [
|
|
("attach", "ATTACH DATABASE '{path}' AS evil;"),
|
|
("vacuum-into", "VACUUM INTO '{path}';"),
|
|
];
|
|
|
|
for (label, template) in cases {
|
|
let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite"));
|
|
let _ = std::fs::remove_file(&target);
|
|
|
|
// 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它,
|
|
// 真正拦下来的必须是 authorizer。
|
|
let malicious = format!(
|
|
"{}\n{}\n",
|
|
super::CC_SWITCH_SQL_EXPORT_HEADER,
|
|
template.replace("{path}", &target.display().to_string())
|
|
);
|
|
|
|
let db = Database::memory()?;
|
|
let result = db.import_sql_string(&malicious);
|
|
|
|
assert!(result.is_err(), "{label} 必须被拒绝");
|
|
// 光报错不够:文件创建发生在 prepare 之后、canonical validation 之前,
|
|
// 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。
|
|
assert!(
|
|
!target.exists(),
|
|
"被拒绝的 {label} 不得在磁盘上留下文件: {}",
|
|
target.display()
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&target);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn public_sql_restore_rejects_temp_schema_growth_atomically() -> Result<(), AppError> {
|
|
let malicious = format!(
|
|
"{}\n\
|
|
CREATE TEMP TABLE scratch_escape(payload BLOB);\n\
|
|
INSERT INTO scratch_escape(payload) VALUES (zeroblob(67108863));\n\
|
|
INSERT INTO scratch_escape(payload) SELECT payload FROM scratch_escape;",
|
|
super::CC_SWITCH_SQL_EXPORT_HEADER
|
|
);
|
|
|
|
for sync in [false, true] {
|
|
let database = Database::memory()?;
|
|
seed_live_restore_state(&database)?;
|
|
let before = logical_snapshot(&database)?;
|
|
let result = if sync {
|
|
database.import_sql_string_for_sync(&malicious)
|
|
} else {
|
|
database.import_sql_string(&malicious)
|
|
};
|
|
assert!(
|
|
result.is_err(),
|
|
"TEMP schema DDL must be rejected by the public {} SQL entry",
|
|
if sync { "sync" } else { "user" }
|
|
);
|
|
assert_eq!(
|
|
logical_snapshot(&database)?,
|
|
before,
|
|
"rejected TEMP growth changed live state"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn public_file_restore_entries_reject_symlink_directory_and_fifo() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create restore file-shape home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let database = Database::memory()?;
|
|
let backup_dir = crate::config::get_app_config_dir().join("backups");
|
|
for filename in ["C:outside.db", "backup.db:stream.db", "../outside.db"] {
|
|
assert!(
|
|
database.restore_from_backup(filename).is_err(),
|
|
"restore filename must be exactly one portable normal component"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let external = test_home.path().join("external-backups");
|
|
fs::create_dir_all(&external).map_err(|error| AppError::io(&external, error))?;
|
|
fs::create_dir_all(
|
|
backup_dir
|
|
.parent()
|
|
.ok_or_else(|| AppError::InvalidInput("backup parent missing".to_string()))?,
|
|
)
|
|
.map_err(|error| AppError::io(&backup_dir, error))?;
|
|
fs::write(external.join("outside.db"), b"outside")
|
|
.map_err(|error| AppError::io(&external, error))?;
|
|
symlink(&external, &backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
|
|
assert!(
|
|
database.restore_from_backup("outside.db").is_err(),
|
|
"a symlinked backup directory must not authorize an outside source"
|
|
);
|
|
fs::remove_file(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
|
|
}
|
|
fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
|
|
|
|
let sql_directory = test_home.path().join("sql-directory");
|
|
fs::create_dir(&sql_directory).map_err(|error| AppError::io(&sql_directory, error))?;
|
|
assert!(database.import_sql(&sql_directory).is_err());
|
|
let binary_directory = backup_dir.join("binary-directory.db");
|
|
fs::create_dir(&binary_directory)
|
|
.map_err(|error| AppError::io(&binary_directory, error))?;
|
|
assert!(database.restore_from_backup("binary-directory.db").is_err());
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::ffi::CString;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let regular = test_home.path().join("regular-source");
|
|
fs::write(®ular, super::CC_SWITCH_SQL_EXPORT_HEADER)
|
|
.map_err(|error| AppError::io(®ular, error))?;
|
|
let sql_symlink = test_home.path().join("symlink.sql");
|
|
symlink(®ular, &sql_symlink).map_err(|error| AppError::io(&sql_symlink, error))?;
|
|
assert!(database.import_sql(&sql_symlink).is_err());
|
|
|
|
let binary_symlink = backup_dir.join("symlink.db");
|
|
symlink(®ular, &binary_symlink)
|
|
.map_err(|error| AppError::io(&binary_symlink, error))?;
|
|
assert!(database.restore_from_backup("symlink.db").is_err());
|
|
|
|
for fifo in [
|
|
test_home.path().join("source-fifo.sql"),
|
|
backup_dir.join("source-fifo.db"),
|
|
] {
|
|
let path = CString::new(fifo.as_os_str().as_bytes())
|
|
.map_err(|error| AppError::InvalidInput(error.to_string()))?;
|
|
let result = unsafe { libc::mkfifo(path.as_ptr(), 0o600) };
|
|
if result != 0 {
|
|
return Err(AppError::io(&fifo, std::io::Error::last_os_error()));
|
|
}
|
|
}
|
|
assert!(database
|
|
.import_sql(&test_home.path().join("source-fifo.sql"))
|
|
.is_err());
|
|
assert!(database.restore_from_backup("source-fifo.db").is_err());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(any(unix, windows))]
|
|
#[test]
|
|
fn restore_file_identity_is_not_a_size_and_timestamp_surrogate() -> Result<(), AppError> {
|
|
use std::fs::FileTimes;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
let directory = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create restore identity test directory".to_string(),
|
|
source: error,
|
|
})?;
|
|
let first_path = directory.path().join("first.db");
|
|
let second_path = directory.path().join("second.db");
|
|
fs::write(&first_path, b"same-size").map_err(|error| AppError::io(&first_path, error))?;
|
|
fs::write(&second_path, b"different").map_err(|error| AppError::io(&second_path, error))?;
|
|
|
|
let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
|
let times = FileTimes::new().set_modified(timestamp);
|
|
let first =
|
|
super::open_nofollow(&first_path).map_err(|error| AppError::io(&first_path, error))?;
|
|
let second = super::open_nofollow(&second_path)
|
|
.map_err(|error| AppError::io(&second_path, error))?;
|
|
first
|
|
.set_times(times)
|
|
.map_err(|error| AppError::io(&first_path, error))?;
|
|
second
|
|
.set_times(times)
|
|
.map_err(|error| AppError::io(&second_path, error))?;
|
|
|
|
let first_metadata = first
|
|
.metadata()
|
|
.map_err(|error| AppError::io(&first_path, error))?;
|
|
let second_metadata = second
|
|
.metadata()
|
|
.map_err(|error| AppError::io(&second_path, error))?;
|
|
assert_eq!(first_metadata.len(), second_metadata.len());
|
|
assert_eq!(
|
|
first_metadata.modified().ok(),
|
|
second_metadata.modified().ok()
|
|
);
|
|
assert!(
|
|
!super::same_open_file_identity(&first, &second)
|
|
.map_err(|error| AppError::io(&first_path, error))?,
|
|
"stable file IDs must distinguish equal-size/equal-mtime files"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn repeated_live_database_init_is_rejected_until_primary_drops() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create single-writer ownership home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
|
|
let primary = Database::init()?;
|
|
let live_path = crate::config::get_app_config_dir().join("cc-switch.db");
|
|
assert_eq!(
|
|
Database::stored_user_version_exceeds_supported(&live_path)?,
|
|
None,
|
|
"the read-only version probe must coexist with the primary writer"
|
|
);
|
|
let duplicate = match Database::init() {
|
|
Ok(_) => {
|
|
return Err(AppError::Message(
|
|
"a second writable live Database unexpectedly initialized".to_string(),
|
|
));
|
|
}
|
|
Err(error) => error,
|
|
};
|
|
assert!(
|
|
matches!(duplicate, AppError::Conflict(_)),
|
|
"duplicate live init must be a structured conflict, got {duplicate:?}"
|
|
);
|
|
|
|
drop(primary);
|
|
let reopened = Database::init()?;
|
|
drop(reopened);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn restore_safety_backup_and_publish_hold_one_live_write_boundary() -> Result<(), AppError> {
|
|
use crate::database::NewProviderAggregate;
|
|
use crate::provider::ProviderMutationInput;
|
|
use serde_json::json;
|
|
use std::sync::{mpsc, Arc, TryLockError};
|
|
use std::time::Duration;
|
|
|
|
fn input(id: &str, name: &str) -> ProviderMutationInput {
|
|
ProviderMutationInput {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
settings_config: json!({"env": {}}),
|
|
website_url: None,
|
|
category: None,
|
|
created_at: Some(1_700_000_000),
|
|
sort_index: None,
|
|
notes: None,
|
|
meta: None,
|
|
icon: None,
|
|
icon_color: None,
|
|
in_failover_queue: false,
|
|
}
|
|
}
|
|
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create restore write-boundary home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let database = Database::init()?;
|
|
database.create_provider(NewProviderAggregate::from_input(
|
|
"pi",
|
|
input("before-restore", "Before Restore"),
|
|
)?)?;
|
|
|
|
let backup_dir = crate::config::get_app_config_dir().join("backups");
|
|
fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
|
|
let source_path = backup_dir.join("write-boundary-source.db");
|
|
let source = canonical_restore_source()?;
|
|
let mut destination = Connection::open(&source_path)?;
|
|
{
|
|
let backup = Backup::new(&source, &mut destination)?;
|
|
super::run_backup_to_completion(&backup, "prepare write-boundary fixture")?;
|
|
}
|
|
drop(destination);
|
|
|
|
let database = Arc::new(database);
|
|
let restore_database = Arc::clone(&database);
|
|
let (safety_ready_tx, safety_ready_rx) = mpsc::channel();
|
|
let (release_restore_tx, release_restore_rx) = mpsc::channel();
|
|
let restore = std::thread::spawn(move || {
|
|
super::TEST_AFTER_SAFETY_BACKUP.with(|slot| {
|
|
*slot.borrow_mut() = Some(Box::new(move || {
|
|
safety_ready_tx.send(()).expect("signal safety-backup seam");
|
|
release_restore_rx
|
|
.recv()
|
|
.expect("release restore after seam assertion");
|
|
}));
|
|
});
|
|
restore_database
|
|
.restore_from_backup("write-boundary-source.db")
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
|
|
safety_ready_rx
|
|
.recv_timeout(Duration::from_secs(5))
|
|
.expect("restore reached the post-safety-backup seam");
|
|
assert!(matches!(
|
|
database.conn.try_lock(),
|
|
Err(TryLockError::WouldBlock)
|
|
));
|
|
|
|
let writer_database = Arc::clone(&database);
|
|
let (writer_started_tx, writer_started_rx) = mpsc::channel();
|
|
let writer = std::thread::spawn(move || -> Result<(), String> {
|
|
writer_started_tx
|
|
.send(())
|
|
.map_err(|error| error.to_string())?;
|
|
let aggregate =
|
|
NewProviderAggregate::from_input("pi", input("late-writer", "Late Writer"))
|
|
.map_err(|error| error.to_string())?;
|
|
writer_database
|
|
.create_provider(aggregate)
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
writer_started_rx
|
|
.recv_timeout(Duration::from_secs(5))
|
|
.expect("concurrent writer started");
|
|
release_restore_tx
|
|
.send(())
|
|
.expect("release restore publication");
|
|
|
|
let safety_id = restore
|
|
.join()
|
|
.expect("restore thread did not panic")
|
|
.map_err(AppError::Message)?;
|
|
writer
|
|
.join()
|
|
.expect("writer thread did not panic")
|
|
.map_err(AppError::Message)?;
|
|
assert!(!safety_id.is_empty());
|
|
|
|
let live_counts: (i64, i64, i64) = {
|
|
let conn = crate::database::lock_conn!(database.conn);
|
|
conn.query_row(
|
|
"SELECT
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'before-restore' AND app_type = 'pi'),
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'remote-provider' AND app_type = 'pi'),
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'late-writer' AND app_type = 'pi')",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
)?
|
|
};
|
|
assert_eq!(live_counts, (0, 1, 1));
|
|
|
|
let safety = Connection::open(backup_dir.join(format!("{safety_id}.db")))?;
|
|
let safety_counts: (i64, i64, i64) = safety.query_row(
|
|
"SELECT
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'before-restore' AND app_type = 'pi'),
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'remote-provider' AND app_type = 'pi'),
|
|
(SELECT COUNT(*) FROM providers
|
|
WHERE id = 'late-writer' AND app_type = 'pi')",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
)?;
|
|
assert_eq!(safety_counts, (1, 0, 0));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn retention_never_removes_the_just_completed_safety_backup() -> Result<(), AppError> {
|
|
use std::fs::FileTimes;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create safety-retention home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let mut settings = get_settings();
|
|
settings.backup_retain_count = Some(1);
|
|
let _settings_guard = AppSettingsGuard::replace(settings)?;
|
|
|
|
let database = Database::init()?;
|
|
{
|
|
let conn = crate::database::lock_conn!(database.conn);
|
|
conn.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES ('live-before-retention', 'pi', 'Live', '{}', '{}')",
|
|
[],
|
|
)?;
|
|
}
|
|
let backup_dir = crate::config::get_app_config_dir().join("backups");
|
|
fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?;
|
|
let source_path = backup_dir.join("future-source.db");
|
|
let source = canonical_restore_source()?;
|
|
let mut destination = Connection::open(&source_path)?;
|
|
{
|
|
let backup = Backup::new(&source, &mut destination)?;
|
|
super::run_backup_to_completion(&backup, "prepare future-mtime restore source")?;
|
|
}
|
|
drop(destination);
|
|
File::open(&source_path)
|
|
.and_then(|file| {
|
|
file.set_times(
|
|
FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)),
|
|
)
|
|
})
|
|
.map_err(|error| AppError::io(&source_path, error))?;
|
|
|
|
let safety_id = database.restore_from_backup("future-source.db")?;
|
|
let safety_path = backup_dir.join(format!("{safety_id}.db"));
|
|
assert!(
|
|
safety_path.is_file(),
|
|
"retention=1 must retain the safety backup even when the source mtime is newer"
|
|
);
|
|
let safety = Connection::open(&safety_path)?;
|
|
let old_live: i64 = safety.query_row(
|
|
"SELECT COUNT(*) FROM providers
|
|
WHERE id = 'live-before-retention' AND app_type = 'pi'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
assert_eq!(old_live, 1);
|
|
|
|
let equal_dir = test_home.path().join("equal-mtime-retention");
|
|
fs::create_dir(&equal_dir).map_err(|error| AppError::io(&equal_dir, error))?;
|
|
let protected = equal_dir.join("protected.db");
|
|
let peer = equal_dir.join("peer.db");
|
|
fs::write(&protected, b"protected").map_err(|error| AppError::io(&protected, error))?;
|
|
fs::write(&peer, b"peer").map_err(|error| AppError::io(&peer, error))?;
|
|
let equal_time = FileTimes::new().set_modified(SystemTime::UNIX_EPOCH);
|
|
File::open(&protected)
|
|
.and_then(|file| file.set_times(equal_time))
|
|
.map_err(|error| AppError::io(&protected, error))?;
|
|
File::open(&peer)
|
|
.and_then(|file| file.set_times(equal_time))
|
|
.map_err(|error| AppError::io(&peer, error))?;
|
|
Database::cleanup_db_backups(&equal_dir, Some(&protected))?;
|
|
assert!(
|
|
protected.is_file(),
|
|
"equal mtime must not defeat protection"
|
|
);
|
|
assert!(!peer.exists());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn incomplete_safety_backup_is_rejected_and_removed() -> Result<(), AppError> {
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create safety-backup contention home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let database = Database::init()?;
|
|
{
|
|
let conn = crate::database::lock_conn!(database.conn);
|
|
conn.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES ('live-before-busy-backup', 'pi', 'Live', '{}', '{}')",
|
|
[],
|
|
)?;
|
|
}
|
|
|
|
let live_path = crate::config::get_app_config_dir().join("cc-switch.db");
|
|
let external = Connection::open(&live_path)?;
|
|
external.execute_batch(
|
|
"BEGIN EXCLUSIVE;
|
|
INSERT INTO settings (key, value) VALUES ('uncommitted-lock', 'held');",
|
|
)?;
|
|
let _retry_guard = BackupRetryGuard::set(0);
|
|
let result = database.backup_database_file();
|
|
external.execute_batch("ROLLBACK;")?;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"a transient SQLite backup result must not be reported as success"
|
|
);
|
|
let backup_dir = crate::config::get_app_config_dir().join("backups");
|
|
let leftovers = fs::read_dir(&backup_dir)
|
|
.map(|entries| entries.filter_map(Result::ok).count())
|
|
.unwrap_or(0);
|
|
assert_eq!(leftovers, 0, "incomplete safety artifacts must be removed");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn incomplete_publish_is_rejected_and_live_database_is_unchanged() -> Result<(), AppError> {
|
|
use std::sync::mpsc;
|
|
|
|
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
|
|
context: "create publish contention home".to_string(),
|
|
source: error,
|
|
})?;
|
|
let _home_guard = TestHomeGuard::set(test_home.path());
|
|
let database = Database::init()?;
|
|
{
|
|
let conn = crate::database::lock_conn!(database.conn);
|
|
conn.execute(
|
|
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
|
VALUES ('live-before-busy-publish', 'pi', 'Live', '{}', '{}')",
|
|
[],
|
|
)?;
|
|
}
|
|
let before = logical_snapshot(&database)?;
|
|
let source = canonical_restore_source()?;
|
|
let sql = Database::dump_sql(&source, &[])?;
|
|
|
|
let live_path = crate::config::get_app_config_dir().join("cc-switch.db");
|
|
let (start_tx, start_rx) = mpsc::channel();
|
|
let (locked_tx, locked_rx) = mpsc::channel();
|
|
let (release_tx, release_rx) = mpsc::channel();
|
|
let locker = std::thread::spawn(move || -> Result<(), String> {
|
|
start_rx.recv().map_err(|error| error.to_string())?;
|
|
let external = Connection::open(live_path).map_err(|error| error.to_string())?;
|
|
external
|
|
.execute_batch("BEGIN EXCLUSIVE;")
|
|
.map_err(|error| error.to_string())?;
|
|
locked_tx.send(()).map_err(|error| error.to_string())?;
|
|
release_rx.recv().map_err(|error| error.to_string())?;
|
|
external
|
|
.execute_batch("ROLLBACK;")
|
|
.map_err(|error| error.to_string())
|
|
});
|
|
super::TEST_AFTER_SAFETY_BACKUP.with(|slot| {
|
|
*slot.borrow_mut() = Some(Box::new(move || {
|
|
start_tx.send(()).expect("start external publish lock");
|
|
locked_rx.recv().expect("external publish lock acquired");
|
|
}));
|
|
});
|
|
|
|
let _retry_guard = BackupRetryGuard::set(0);
|
|
let result = database.import_sql_string(&sql);
|
|
release_tx
|
|
.send(())
|
|
.expect("release external publish lock after restore result");
|
|
locker
|
|
.join()
|
|
.expect("publish-lock thread did not panic")
|
|
.map_err(AppError::Message)?;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"a non-Done publication must not be reported as success"
|
|
);
|
|
assert_eq!(
|
|
logical_snapshot(&database)?,
|
|
before,
|
|
"failed publication changed the live database"
|
|
);
|
|
let backup_dir = crate::config::get_app_config_dir().join("backups");
|
|
let safety_paths = fs::read_dir(&backup_dir)
|
|
.map_err(|error| AppError::io(&backup_dir, error))?
|
|
.filter_map(Result::ok)
|
|
.map(|entry| entry.path())
|
|
.filter(|path| path.extension().is_some_and(|extension| extension == "db"))
|
|
.collect::<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(())
|
|
}
|
|
}
|