mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
pub(crate) mod backup;
|
||||
#[cfg(test)]
|
||||
mod backup_restore_certification;
|
||||
#[cfg(test)]
|
||||
mod backup_restore_certification_ext;
|
||||
mod dao;
|
||||
mod migration;
|
||||
mod schema;
|
||||
|
||||
+634
-142
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user