mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(database): enforce canonical restore trust boundaries
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::{lock_conn, Database, SCHEMA_VERSION};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
@@ -26,6 +26,14 @@ impl CanonicalStage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects whether schema migrations may repair locally trusted historical
|
||||
/// data or must fail closed on an input that would require reconciliation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MigrationRunContext {
|
||||
LocalUpgrade,
|
||||
UntrustedRestore,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum CanonicalRestoreClass {
|
||||
@@ -525,9 +533,9 @@ impl Database {
|
||||
|
||||
// Starting from version zero exercises the normal migration chain and
|
||||
// yields the exact same current objects as a fresh production database.
|
||||
Self::create_tables_on_conn(&connection)?;
|
||||
Self::create_tables_on_conn(&connection, MigrationRunContext::LocalUpgrade)?;
|
||||
Self::set_user_version(&connection, 0)?;
|
||||
Self::apply_schema_migrations_on_conn(&connection)?;
|
||||
Self::apply_schema_migrations_on_conn(&connection, MigrationRunContext::LocalUpgrade)?;
|
||||
if Self::get_user_version(&connection)? != SCHEMA_VERSION {
|
||||
return Err(AppError::Database(
|
||||
"canonical stage factory did not reach the current schema version".to_string(),
|
||||
@@ -581,11 +589,14 @@ impl Database {
|
||||
/// 创建所有数据库表
|
||||
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::create_tables_on_conn(&conn)
|
||||
Self::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
}
|
||||
|
||||
/// 在指定连接上创建表(供迁移和测试使用)
|
||||
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||
pub(crate) fn create_tables_on_conn(
|
||||
conn: &Connection,
|
||||
context: MigrationRunContext,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. Providers 表
|
||||
Self::create_canonical_table_on_conn(conn, "providers", true)?;
|
||||
|
||||
@@ -685,7 +696,9 @@ impl Database {
|
||||
// 兼容旧数据库:
|
||||
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert;
|
||||
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
|
||||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||||
if context == MigrationRunContext::LocalUpgrade
|
||||
&& Self::has_column(conn, "proxy_config", "app_type")?
|
||||
{
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
@@ -865,96 +878,92 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 修复跑过未发布开发版的库:current 标记曾是全局 key,现按应用分组
|
||||
// (随 v12 定稿为 current_profile_id_<scope>,不单独 bump 版本)
|
||||
if conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value)
|
||||
SELECT 'current_profile_id_claude', value FROM settings
|
||||
WHERE key = 'current_profile_id'",
|
||||
if context == MigrationRunContext::LocalUpgrade {
|
||||
// These compatibility repairs are intentionally local-only. An
|
||||
// untrusted restore must be accepted by its declared migration
|
||||
// version and canonical validation, never silently normalized by
|
||||
// startup repair code.
|
||||
if conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value)
|
||||
SELECT 'current_profile_id_claude', value FROM settings
|
||||
WHERE key = 'current_profile_id'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = conn.execute("DELETE FROM settings WHERE key = 'current_profile_id'", []);
|
||||
}
|
||||
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = conn.execute("DELETE FROM settings WHERE key = 'current_profile_id'", []);
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
|
||||
[],
|
||||
);
|
||||
|
||||
if Self::table_exists(conn, "proxy_config")?
|
||||
&& !Self::has_column(conn, "proxy_config", "app_type")?
|
||||
{
|
||||
Self::migrate_proxy_config_to_per_app(conn)?;
|
||||
}
|
||||
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"in_failover_queue",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||
let _ = conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||
ON providers(app_type, in_failover_queue, sort_index)",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
|
||||
// 尝试添加超时配置列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
|
||||
[],
|
||||
);
|
||||
|
||||
// 兼容:若旧版 proxy_config 仍为单例结构(无 app_type),则在启动时直接转换为三行结构
|
||||
// 说明:user_version=2 时不会再触发 v1->v2 迁移,但新代码查询依赖 app_type 列。
|
||||
if Self::table_exists(conn, "proxy_config")?
|
||||
&& !Self::has_column(conn, "proxy_config", "app_type")?
|
||||
{
|
||||
Self::migrate_proxy_config_to_per_app(conn)?;
|
||||
}
|
||||
|
||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"in_failover_queue",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||
|
||||
// 为故障转移队列创建索引(基于 providers 表)
|
||||
let _ = conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||
ON providers(app_type, in_failover_queue, sort_index)",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用 Schema 迁移
|
||||
pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::apply_schema_migrations_on_conn(&conn)
|
||||
Self::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
|
||||
}
|
||||
|
||||
/// 在指定连接上应用 Schema 迁移
|
||||
pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||
pub(crate) fn apply_schema_migrations_on_conn(
|
||||
conn: &Connection,
|
||||
context: MigrationRunContext,
|
||||
) -> Result<(), AppError> {
|
||||
conn.execute("SAVEPOINT schema_migration;", [])
|
||||
.map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
|
||||
|
||||
@@ -1057,7 +1066,7 @@ impl Database {
|
||||
log::info!(
|
||||
"迁移数据库从 v16 到 v17(添加 Pi aggregate 与设备本地 ledger)"
|
||||
);
|
||||
Self::migrate_v16_to_v17(conn)?;
|
||||
Self::migrate_v16_to_v17(conn, context)?;
|
||||
Self::set_user_version(conn, 17)?;
|
||||
}
|
||||
_ => {
|
||||
@@ -2075,8 +2084,35 @@ impl Database {
|
||||
/// v16 -> v17: add the Pi desired bit, lossless endpoint metadata, and
|
||||
/// device-local ownership ledgers. No ownership is inferred during
|
||||
/// migration; both ledgers intentionally start empty.
|
||||
fn migrate_v16_to_v17(conn: &Connection) -> Result<(), AppError> {
|
||||
fn migrate_v16_to_v17(conn: &Connection, context: MigrationRunContext) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "provider_endpoints")? {
|
||||
if context == MigrationRunContext::UntrustedRestore {
|
||||
let duplicate = conn
|
||||
.query_row(
|
||||
"SELECT provider_id, app_type, url, COUNT(*)
|
||||
FROM provider_endpoints
|
||||
GROUP BY provider_id, app_type, url
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some((provider_id, app_type, url, count)) = duplicate {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"untrusted v16 restore contains {count} duplicate endpoint rows \
|
||||
for ({provider_id}, {app_type}, {url}); migration repair is forbidden"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Self::add_column_if_missing(conn, "provider_endpoints", "last_used", "INTEGER")?;
|
||||
// Older builds allowed duplicate rows for one logical endpoint.
|
||||
// Merge their timestamps before rebuilding from the canonical
|
||||
@@ -3650,7 +3686,7 @@ mod tests {
|
||||
)?;
|
||||
Database::set_user_version(&conn, 12)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
@@ -3677,7 +3713,7 @@ mod tests {
|
||||
#[test]
|
||||
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Database::create_tables_on_conn(&conn)?;
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
|
||||
@@ -3685,7 +3721,7 @@ mod tests {
|
||||
)?;
|
||||
Database::set_user_version(&conn, 13)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
let grok_rows: i64 = conn.query_row(
|
||||
@@ -3727,7 +3763,7 @@ mod tests {
|
||||
)?;
|
||||
Database::set_user_version(&conn, 14)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
@@ -3755,7 +3791,7 @@ mod tests {
|
||||
#[test]
|
||||
fn migrate_v15_to_v16_resets_only_codex_session_usage() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Database::create_tables_on_conn(&conn)?;
|
||||
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
conn.execute_batch(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
@@ -3776,7 +3812,7 @@ mod tests {
|
||||
)?;
|
||||
Database::set_user_version(&conn, 15)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
let counts: (i64, i64, i64, i64) = conn.query_row(
|
||||
@@ -3822,7 +3858,7 @@ mod tests {
|
||||
)?;
|
||||
Database::set_user_version(&conn, 16)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
|
||||
Reference in New Issue
Block a user