fix(database): enforce canonical restore trust boundaries

This commit is contained in:
SaladDay
2026-08-01 16:01:00 +00:00
parent 89961dff28
commit 28530ff641
8 changed files with 1064 additions and 140 deletions
+129 -93
View File
@@ -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(