fix(database): fail closed across restore migrations

Classify every historical migration boundary, thread the restore context through the full chain, reject repair or synthesis for untrusted inputs, and add SQL/binary public-entry certification for damaged and valid legacy fixtures.
This commit is contained in:
SaladDay
2026-08-01 17:04:54 +00:00
parent 28530ff641
commit 29cccd5bd3
3 changed files with 1132 additions and 142 deletions
@@ -0,0 +1,496 @@
#![cfg(test)]
// 裁决方授权:扩展认证沿用核心套件的纯风格类 lint 豁免。安全/正确性
// lint 不在豁免范围内。
#![allow(clippy::type_complexity)]
//! 前置工程 B:历史迁移 fail-closed 扩展认证。
//!
//! 本文件只扩展、不得替代 `backup_restore_certification.rs`。行为测试必须
//! 穿过两个公开入口,证明 v1 旧式 proxy 配置中的损坏不会被默认值修复后
//! 静默发布。
use super::Database;
use crate::error::AppError;
use rusqlite::{backup::Backup, Connection};
use serial_test::serial;
use std::path::Path;
use std::time::Duration;
#[derive(Clone, Copy, Debug)]
enum RestoreEntry {
Sql,
Binary,
}
#[derive(Clone, Copy, Debug)]
enum V1Damage {
None,
MissingPrimaryRow,
WrongStorageClass,
FieldDecodeFailure,
MissingCircuitRow,
MissingSettingRow,
}
struct TestHomeGuard(Option<std::ffi::OsString>);
impl TestHomeGuard {
fn set(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"),
}
}
}
fn v1_proxy_fixture(damage: V1Damage) -> String {
let proxy_row = match damage {
V1Damage::MissingPrimaryRow => "(2, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)",
V1Damage::WrongStorageClass => "(1, 0, '127.0.0.1', X'00', 1, 3, 60, 120, 600)",
V1Damage::FieldDecodeFailure => "(1, 0, '127.0.0.1', 15721, 1, 3, 2147483648, 120, 600)",
V1Damage::None | V1Damage::MissingCircuitRow | V1Damage::MissingSettingRow => {
"(1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)"
}
};
let circuit_row = if matches!(damage, V1Damage::MissingCircuitRow) {
""
} else {
"INSERT INTO circuit_breaker_config VALUES (1, 5, 2, 60, 0.5, 10);"
};
let settings_rows = if matches!(damage, V1Damage::MissingSettingRow) {
""
} else {
"INSERT INTO settings (key, value) VALUES
('proxy_takeover_claude', 'false'),
('auto_failover_enabled_claude', 'false'),
('proxy_takeover_codex', 'false'),
('auto_failover_enabled_codex', 'false'),
('proxy_takeover_gemini', 'false'),
('auto_failover_enabled_gemini', 'false');"
};
format!(
"-- CC Switch SQLite 导出
BEGIN TRANSACTION;
CREATE TABLE proxy_config (
id INTEGER PRIMARY KEY,
proxy_enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL,
listen_port INTEGER NOT NULL,
enable_logging INTEGER NOT NULL DEFAULT 1,
max_retries INTEGER NOT NULL DEFAULT 3,
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
non_streaming_timeout INTEGER NOT NULL DEFAULT 600
);
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 {proxy_row};
CREATE TABLE circuit_breaker_config (
id INTEGER PRIMARY KEY,
failure_threshold INTEGER NOT NULL,
success_threshold INTEGER NOT NULL,
timeout_seconds INTEGER NOT NULL,
error_rate_threshold REAL NOT NULL,
min_requests INTEGER NOT NULL
);
{circuit_row}
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
);
{settings_rows}
PRAGMA user_version = 1;
COMMIT;"
)
}
fn write_binary_fixture(sql: &str, path: &Path) -> Result<(), AppError> {
let source = Connection::open_in_memory()?;
source.execute_batch(sql)?;
let mut destination = Connection::open(path)?;
{
let backup = Backup::new(&source, &mut destination)?;
backup.run_to_completion(64, Duration::ZERO, None)?;
}
Ok(())
}
fn run_restore_fixture(
target: &Database,
fixture: &str,
entry: RestoreEntry,
filename: &str,
) -> Result<String, AppError> {
match entry {
RestoreEntry::Sql => target.import_sql_string(fixture),
RestoreEntry::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))?;
write_binary_fixture(fixture, &backup_dir.join(filename))?;
target.restore_from_backup(filename)
}
}
}
fn seed_live_sentinel(target: &Database) -> Result<(), AppError> {
let conn = super::lock_conn!(target.conn);
conn.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, meta, is_current
) VALUES (
'migration-cert-live', 'claude', 'live sentinel', '{}', '{}', 0
)",
[],
)?;
Ok(())
}
fn assert_live_sentinel_unchanged(target: &Database) -> Result<(), AppError> {
let conn = super::lock_conn!(target.conn);
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM providers
WHERE id = 'migration-cert-live'
AND app_type = 'claude'
AND name = 'live sentinel'",
[],
|row| row.get(0),
)?;
assert_eq!(
count, 1,
"failed restore must leave the live database intact"
);
Ok(())
}
fn current_schema_fixture(
version: i32,
mutate: impl FnOnce(&Connection) -> Result<(), AppError>,
) -> Result<String, AppError> {
let source = Database::memory()?;
{
let conn = super::lock_conn!(source.conn);
mutate(&conn)?;
Database::set_user_version(&conn, version)?;
}
source.export_sql_string()
}
fn assert_fixture_rejected(
label: &str,
expected_step: &str,
fixture: &str,
) -> Result<(), AppError> {
for entry in [RestoreEntry::Sql, RestoreEntry::Binary] {
let target = Database::memory()?;
seed_live_sentinel(&target)?;
let filename = format!("{label}-{entry:?}.db").to_ascii_lowercase();
let error = match run_restore_fixture(&target, fixture, entry, &filename) {
Ok(value) => {
panic!("{label} via {entry:?} silently survived repair-requiring input: {value:?}")
}
Err(error) => error,
};
assert!(
matches!(error, AppError::InvalidInput(_)),
"{label} via {entry:?} must return structured InvalidInput, got {error:?}"
);
assert!(
error.to_string().contains(expected_step),
"{label} via {entry:?} failed at the wrong boundary: {error}"
);
assert_live_sentinel_unchanged(&target)?;
}
Ok(())
}
#[test]
#[serial]
fn v1_damaged_proxy_rows_fail_closed_through_sql_and_binary_entries() -> Result<(), AppError> {
let home = tempfile::tempdir().map_err(|error| AppError::IoContext {
context: "create migration certification home".to_string(),
source: error,
})?;
let _home_guard = TestHomeGuard::set(home.path());
for damage in [
V1Damage::MissingPrimaryRow,
V1Damage::WrongStorageClass,
V1Damage::FieldDecodeFailure,
V1Damage::MissingCircuitRow,
V1Damage::MissingSettingRow,
] {
for entry in [RestoreEntry::Sql, RestoreEntry::Binary] {
let target = Database::memory()?;
seed_live_sentinel(&target)?;
let fixture = v1_proxy_fixture(damage);
let filename = format!("v1-{damage:?}-{entry:?}.db").to_ascii_lowercase();
let error = run_restore_fixture(&target, &fixture, entry, &filename)
.expect_err("damaged v1 restore must fail closed");
assert!(
matches!(error, AppError::InvalidInput(_)),
"migration damage must be a structured InvalidInput, got {error:?}"
);
assert_live_sentinel_unchanged(&target)?;
}
}
Ok(())
}
#[test]
#[serial]
fn valid_v1_proxy_rows_migrate_losslessly_through_sql_and_binary_entries() -> Result<(), AppError> {
let home = tempfile::tempdir().map_err(|error| AppError::IoContext {
context: "create valid v1 migration certification home".to_string(),
source: error,
})?;
let _home_guard = TestHomeGuard::set(home.path());
let fixture = v1_proxy_fixture(V1Damage::None);
for entry in [RestoreEntry::Sql, RestoreEntry::Binary] {
let target = Database::memory()?;
let filename = format!("valid-v1-{entry:?}.db").to_ascii_lowercase();
run_restore_fixture(&target, &fixture, entry, &filename)?;
let conn = super::lock_conn!(target.conn);
let restored: (i64, String, i64) = conn.query_row(
"SELECT
COUNT(*),
MIN(listen_address),
MIN(listen_port)
FROM proxy_config",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(
restored,
(4, "127.0.0.1".to_string(), 15721),
"valid authoritative v1 fields must survive {entry:?}"
);
}
Ok(())
}
#[test]
#[serial]
fn every_repair_class_has_a_public_entry_fail_closed_sentinel() -> Result<(), AppError> {
let home = tempfile::tempdir().map_err(|error| AppError::IoContext {
context: "create migration repair certification home".to_string(),
source: error,
})?;
let _home_guard = TestHomeGuard::set(home.path());
let fixtures = vec![
(
"v1-unprefixed-skill",
"v1->v2 skills",
current_schema_fixture(1, |conn| {
conn.execute_batch(
"DROP TABLE skills;
CREATE TABLE skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
);
INSERT INTO skills (key, installed, installed_at)
VALUES ('missing-prefix', 1, 10);",
)?;
Ok(())
})?,
),
(
"v2-nonempty-skills",
"v2->v3 skills SSOT rebuild",
current_schema_fixture(2, |conn| {
conn.execute_batch(
"DROP TABLE skills;
CREATE TABLE skills (
directory TEXT NOT NULL,
app_type TEXT NOT NULL,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
);
INSERT INTO skills
(directory, app_type, installed, installed_at)
VALUES ('legacy-skill', 'claude', 1, 10);",
)?;
Ok(())
})?,
),
(
"v5-invalid-provider-meta",
"v5->v6 provider meta",
current_schema_fixture(5, |conn| {
conn.execute(
"INSERT INTO providers
(id, app_type, name, settings_config, meta)
VALUES ('bad-meta', 'claude', 'bad meta', '{}', '{')",
[],
)?;
Ok(())
})?,
),
(
"v5-provider-meta-normalization",
"v5->v6 provider meta",
current_schema_fixture(5, |conn| {
conn.execute(
"INSERT INTO providers
(id, app_type, name, settings_config, meta)
VALUES (
'copilot-meta', 'claude', 'copilot meta', '{}',
'{\"usage_script\":{\"template_type\":\"copilot\"}}'
)",
[],
)?;
Ok(())
})?,
),
(
"v7-pricing-correction",
"v7->v8 model pricing correction",
current_schema_fixture(7, |conn| {
conn.execute(
"UPDATE model_pricing
SET input_cost_per_million = '999'
WHERE model_id = 'deepseek-v3.2'",
[],
)?;
Ok(())
})?,
),
(
"v8-pricing-reseed",
"v8->v9 model-pricing replacement",
current_schema_fixture(8, |conn| {
conn.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 ('untrusted-extra', 'extra', '1', '1', '0', '0')",
[],
)?;
Ok(())
})?,
),
(
"v10-rollup-synthesis",
"v10->v11 usage rollup rebuild",
current_schema_fixture(10, |conn| {
conn.execute(
"INSERT INTO usage_daily_rollups
(date, app_type, provider_id, model)
VALUES ('2026-08-01', 'claude', 'legacy', 'legacy-model')",
[],
)?;
Ok(())
})?,
),
(
"v13-grokbuild-synthesis",
"v13->v14 proxy rebuild",
current_schema_fixture(13, |conn| {
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
Ok(())
})?,
),
(
"v13-missing-column-fallback",
"v13->v14 proxy rebuild",
current_schema_fixture(13, |conn| {
conn.execute_batch("ALTER TABLE proxy_config DROP COLUMN live_takeover_active;")?;
Ok(())
})?,
),
(
"v13-wrong-storage",
"v13->v14 proxy rebuild",
current_schema_fixture(13, |conn| {
conn.execute(
"UPDATE proxy_config SET listen_port = X'00'
WHERE app_type = 'claude'",
[],
)?;
Ok(())
})?,
),
(
"v15-codex-log-reset",
"v15->v16 Codex usage reset",
current_schema_fixture(15, |conn| {
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
latency_ms, status_code, created_at, data_source
) VALUES (
'untrusted-codex-log', '_codex_session', 'codex', 'legacy',
1, 200, 1, 'codex_session'
)",
[],
)?;
Ok(())
})?,
),
];
for (label, expected_step, fixture) in fixtures {
assert_fixture_rejected(label, expected_step, &fixture)?;
}
Ok(())
}
#[test]
fn migration_classification_and_no_swallowing_guard_cover_v1_through_v17() {
let source = include_str!("schema.rs");
for from in 1..17 {
let to = from + 1;
let classification_row = format!("| v{from}→v{to}");
assert!(
source.contains(&classification_row),
"migration classification table is missing v{from}->v{to}"
);
let dispatch = format!("Self::migrate_v{from}_to_v{to}(conn, context)?;");
assert!(
source.contains(&dispatch),
"v{from}->v{to} must receive MigrationRunContext at dispatch"
);
}
let migration_start = source
.find("fn migrate_v1_to_v2")
.expect("v1 migration exists");
let migration_end = source
.find("/// 插入默认模型定价数据")
.expect("migration span end exists");
let migration_span = &source[migration_start..migration_end];
for forbidden in [
".unwrap_or(",
".unwrap_or_else(",
".unwrap_or_default(",
".ok()",
"if let Ok(",
"let _ =",
] {
assert!(
!migration_span.contains(forbidden),
"historical migration span contains forbidden error swallowing: {forbidden}"
);
}
assert!(
source.contains("untrusted schema migration v{version}->v{} failed"),
"the migration boundary must map non-structured failures to InvalidInput"
);
}
+2
View File
@@ -26,6 +26,8 @@
pub(crate) mod backup; pub(crate) mod backup;
#[cfg(test)] #[cfg(test)]
mod backup_restore_certification; mod backup_restore_certification;
#[cfg(test)]
mod backup_restore_certification_ext;
mod dao; mod dao;
mod migration; mod migration;
mod schema; mod schema;
File diff suppressed because it is too large Load Diff