From 3fa6b1f158b4e609b1588affb366bd90fb11878e Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 2 Aug 2026 05:29:48 +0000 Subject: [PATCH] refactor(database): scope untrusted restore to N/N-1 --- docs/pi-support-restructure-zh.md | 9 + src-tauri/src/database/backup.rs | 46 +- .../backup_restore_certification_ext.rs | 1054 ++++----------- src-tauri/src/database/migration_source.rs | 1199 +++-------------- src-tauri/src/database/mod.rs | 32 +- src-tauri/src/database/schema.rs | 27 +- 6 files changed, 536 insertions(+), 1831 deletions(-) diff --git a/docs/pi-support-restructure-zh.md b/docs/pi-support-restructure-zh.md index 2273fb13f..2a37b36b2 100644 --- a/docs/pi-support-restructure-zh.md +++ b/docs/pi-support-restructure-zh.md @@ -50,6 +50,15 @@ > 文档考据说明:修正案 1/2(`pi-support-contracts-amendment-*.md`)的条款已按其自身要求**合并**进 `pi-support-contracts-zh.md` 与 `pi-support-review-contract-zh.md`,独立文件已随合并删除,这是预期状态而非丢失;本文引用的"修正案 2 §F/E1"以合并后的规范文档对应章节为准。 +## 4.5 范围修订(用户批准,2026-08-02) + +前置 B 的不可信恢复(SQL/binary 双入口)缩围为**仅接受 user_version N 与 N−1** +(当前 16/17):迁移语义 invariant 家族四次复发均位于历史迁移链,其对抗深度与 +"导入远古备份"的产品价值长尾严重不匹配。v1–v15 输入结构化拒绝;**本地升级链 +v1→17 完全不受影响**(LocalUpgrade 就地迁移照旧);升级前备份(N−1)回滚路径 +保留并专测。v1–v15 恢复立项为"历史备份导入"独立未来工程,已建成的 +MigrationSourceSpec 架构留作其地基。 + ## 5. 冻结事实(2026-08-01) - 分支 `feat/pi-native-support`,HEAD = 10f2dacb(R4 检查点),工作树干净; diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index d4f2fdad6..d7b954c36 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -1322,7 +1322,9 @@ impl UntrustedScratch { scratch.connection.authorizer( None::) -> rusqlite::hooks::Authorization>, ); - result.map_err(|error| AppError::Database(format!("execute SQL import: {error}")))?; + result.map_err(|error| { + AppError::InvalidInput(format!("execute untrusted SQL import: {error}")) + })?; scratch.finish_input() } @@ -1355,6 +1357,10 @@ impl UntrustedScratch { "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) @@ -1829,16 +1835,19 @@ fn canonical_user_tables( let mut statement = conn .prepare( "SELECT name FROM sqlite_schema - WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + 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::, _>>() + .collect::, _>>() .map_err(|error| AppError::Database(error.to_string()))?; - Ok(tables) + Ok(tables + .into_iter() + .filter(|name| !super::is_sqlite_internal_table_name(name)) + .collect()) } fn assert_restore_policy_coverage( @@ -2444,15 +2453,16 @@ impl Database { 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()))?; - // 跳过 SQLite 内部对象(如 sqlite_sequence) - if name.starts_with("sqlite_") { + // 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" && !name.starts_with("sqlite_") { + if obj_type == "table" && !super::is_sqlite_internal_table_name(&name) { tables.push(name); } } @@ -4008,9 +4018,8 @@ mod tests { })?; let _home_guard = TestHomeGuard::set(test_home.path()); - // The source-spec authority supports exactly v1..v17. A legacy v0 - // label must fail before migration DDL instead of being interpreted - // through the permissive local-upgrade 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() @@ -4042,9 +4051,8 @@ mod tests { .expect_err("untrusted v0 is outside the declared source-spec range"); assert!( matches!(error, AppError::InvalidInput(_)) - && error - .to_string() - .contains("unsupported restore user_version 0"), + && error.to_string().contains("user_version=0") + && error.to_string().contains("备份版本过旧"), "v0 must fail at source recognition via entry {entry_index}: {error:?}" ); } @@ -4095,10 +4103,10 @@ mod tests { assert_eq!(sentinel, (None, 0, 0, 0)); } - // Every supported version is materialized from its exact source spec. + // 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 current database with an old version. - for version in 1..=SCHEMA_VERSION { + // 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() @@ -4128,11 +4136,7 @@ mod tests { .contains(&format!("\"migrationVersion\":{version}")), "Pi settings payload was not preserved for v{version}" ); - assert_eq!( - restored.1, - if version == 1 { "1.0" } else { "1.25" }, - "provider migration sentinel 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"), diff --git a/src-tauri/src/database/backup_restore_certification_ext.rs b/src-tauri/src/database/backup_restore_certification_ext.rs index c07e390ca..cde5aacfe 100644 --- a/src-tauri/src/database/backup_restore_certification_ext.rs +++ b/src-tauri/src/database/backup_restore_certification_ext.rs @@ -2,12 +2,12 @@ // 裁决方授权:扩展认证沿用核心套件的纯风格类 lint 豁免。安全/正确性 // lint 不在豁免范围内。 #![allow(clippy::type_complexity)] -//! 前置工程 B:版本化 source spec 与历史迁移全函数映射认证。 +//! 前置工程 B:N/N−1 source spec 与 v16→v17 全函数映射认证。 //! //! 本文件只扩展、不得替代 `backup_restore_certification.rs`。行为测试必须 -//! 穿过 SQL 与 binary 两个公开入口,证明 v1..v17 的精确源形状先于任何 -//! migration DDL 被认证、每个 portable 源字段被映射或结构化拒绝,并且旧式 -//! proxy/skill 等 typed transform 不会以默认值修复损坏输入。 +//! 穿过 SQL 与 binary 两个公开入口,证明只有 v16/v17 可进入不可信恢复, +//! v16 的每个 portable 源字段无损映射到 v17,而 v1..v15 在任何迁移尝试前 +//! 以“备份版本过旧”结构化拒绝。更早版本恢复已缩为未来“历史备份导入”工程。 use super::migration_source::{ exact_migration_source_for_test, migration_source_spec, pinned_pi_provider_settings_for_test, @@ -15,7 +15,7 @@ use super::migration_source::{ }; use super::Database; use crate::error::AppError; -use rusqlite::{backup::Backup, params_from_iter, types::Value, Connection}; +use rusqlite::{backup::Backup, params_from_iter, types::Value, Connection, OpenFlags}; use serial_test::serial; use std::collections::BTreeMap; use std::path::Path; @@ -27,41 +27,6 @@ enum RestoreEntry { Binary, } -#[derive(Clone, Copy, Debug)] -enum V1Damage { - None, - MissingPrimaryRow, - DuplicatePrimaryRow, - WrongStorageClass, - FieldDecodeFailure, - MissingCircuitRow, - DuplicateCircuitRow, - MissingSettingRow, - DuplicateSettingRow, -} - -#[derive(Debug, PartialEq)] -struct RestoredV1ProxyRow { - app_type: String, - proxy_enabled: i64, - listen_address: String, - listen_port: i64, - enable_logging: i64, - enabled: i64, - auto_failover_enabled: i64, - max_retries: i64, - streaming_first_byte_timeout: i64, - streaming_idle_timeout: i64, - non_streaming_timeout: i64, - circuit_failure_threshold: i64, - circuit_success_threshold: i64, - circuit_timeout_seconds: i64, - circuit_error_rate_threshold: f64, - circuit_min_requests: i64, - created_at: String, - updated_at: String, -} - struct TestHomeGuard(Option); impl TestHomeGuard { @@ -352,17 +317,6 @@ fn seed_full_source_sentinels(source: &Connection, version: i32) -> Result<(), A params_from_iter(values), )?; } - if version == 1 { - source.execute_batch( - "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');", - )?; - } Ok(()) } @@ -498,161 +452,61 @@ fn assert_nonportable_source_rows_not_published( Ok(()) } -fn assert_legacy_skill_mapping( - target: &Database, - version: i32, - entry: RestoreEntry, -) -> Result<(), AppError> { - if !matches!(version, 1 | 2) { - return Ok(()); - } - let directory = format!("pi-skill-v{version}"); - let id = format!("claude:{directory}"); +fn assert_v16_structural_additions(target: &Database, entry: RestoreEntry) -> Result<(), AppError> { let conn = super::lock_conn!(target.conn); - let restored: (String, String, String, i64, i64, i64, i64) = conn.query_row( - "SELECT id, name, directory, enabled_claude, enabled_codex, - enabled_gemini, installed_at - FROM skills WHERE id = ?1", - [&id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - )) - }, + let structural_additions: (i64, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM provider_endpoints + WHERE last_used IS NOT NULL), + (SELECT COUNT(*) FROM skills WHERE enabled_pi != 0), + (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!( - restored, - ( - id, - directory.clone(), - directory, - 1, - 0, - 0, - 1_700_100_000 + i64::from(version), - ), - "v{version} via {entry:?} did not preserve the typed legacy skill row" + structural_additions, + (0, 0, 0, 0), + "v16 via {entry:?} must use only the declared v17 structural defaults" ); Ok(()) } -fn seed_required_v1_rows(source: &Connection) -> Result<(), AppError> { - 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, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600); - INSERT INTO circuit_breaker_config ( - id, failure_threshold, success_threshold, timeout_seconds, - error_rate_threshold, min_requests - ) VALUES (1, 5, 2, 60, 0.5, 10); - 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');", - )?; - Ok(()) -} - -fn v1_proxy_fixture(damage: V1Damage) -> Result { - let source = exact_source_connection(1)?; - 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 => "(1, 1, '10.20.30.40', 23456, 0, 9, 61, 122, 603)", - V1Damage::DuplicatePrimaryRow - | V1Damage::DuplicateCircuitRow - | V1Damage::DuplicateSettingRow => "(1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)", - V1Damage::MissingCircuitRow | V1Damage::MissingSettingRow => { - "(1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)" - } - }; - let circuit_row = if matches!(damage, V1Damage::MissingCircuitRow) { - None - } else if matches!(damage, V1Damage::None) { - Some((1_i64, 7_i64, 4_i64, 73_i64, 0.375_f64, 21_i64)) - } else { - Some((1_i64, 5_i64, 2_i64, 60_i64, 0.5_f64, 10_i64)) - }; - - source.execute( - &format!( - "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}" - ), - [], - )?; - if matches!(damage, V1Damage::DuplicatePrimaryRow) { - source.execute( - "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 (2, 0, '127.0.0.2', 15722, 1, 4, 62, 123, 604)", +fn production_v16_database_fixture() -> Result<(String, Vec), AppError> +{ + let database = Database::memory()?; + let current_spec = migration_source_spec(super::SCHEMA_VERSION)?; + let conn = super::lock_conn!(database.conn); + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + for table in current_spec.tables.iter().rev() { + conn.execute( + &format!("DELETE FROM {}", quoted_identifier(table.name)), [], )?; } - if let Some((id, failure, success, timeout, rate, requests)) = circuit_row { - source.execute( - "INSERT INTO circuit_breaker_config ( - id, failure_threshold, success_threshold, timeout_seconds, - error_rate_threshold, min_requests - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![id, failure, success, timeout, rate, requests], - )?; - } - if matches!(damage, V1Damage::DuplicateCircuitRow) { - source.execute( - "INSERT INTO circuit_breaker_config ( - id, failure_threshold, success_threshold, timeout_seconds, - error_rate_threshold, min_requests - ) VALUES (2, 8, 5, 74, 0.25, 22)", - [], - )?; - } - if !matches!(damage, V1Damage::MissingSettingRow) { - let settings = if matches!(damage, V1Damage::None) { - "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'), - ('proxy_takeover_future', 'preserve-me');" - } 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');" - }; - source.execute_batch(settings)?; - if matches!(damage, V1Damage::DuplicateSettingRow) { - source.execute( - "INSERT INTO settings (key, value) - VALUES ('proxy_takeover_claude', 'true')", - [], - )?; - } - } - dump_exact_source(&source) + conn.execute_batch( + "DROP TABLE pi_provider_projections; + DROP TABLE skill_deployments; + ALTER TABLE provider_endpoints DROP COLUMN last_used; + ALTER TABLE skills DROP COLUMN enabled_pi;", + )?; + Database::set_user_version(&conn, super::SCHEMA_VERSION - 1)?; + seed_full_source_sentinels(&conn, super::SCHEMA_VERSION - 1)?; + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + let foreign_key_failures: i64 = + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + assert_eq!( + foreign_key_failures, 0, + "production-shaped v16 backup fixture must satisfy its foreign keys" + ); + Database::validate_migration_source_version(&conn, super::SCHEMA_VERSION - 1)?; + let projections = capture_portable_sentinel_projections(&conn, super::SCHEMA_VERSION - 1)?; + let fixture = dump_exact_source(&conn)?; + drop(conn); + drop(database); + Ok((fixture, projections)) } fn write_binary_fixture(sql: &str, path: &Path) -> Result<(), AppError> { @@ -714,15 +568,17 @@ fn assert_live_sentinel_unchanged(target: &Database) -> Result<(), AppError> { Ok(()) } -fn current_schema_fixture( - version: i32, - mutate: impl FnOnce(&Connection) -> Result<(), AppError>, -) -> Result { - let source = exact_source_connection(version)?; - if version == 1 { - seed_required_v1_rows(&source)?; - } - mutate(&source)?; +fn obsolete_source_fixture(version: i32) -> Result { + let source = Connection::open_in_memory()?; + source.execute( + "CREATE TABLE migration_must_not_run (marker TEXT NOT NULL)", + [], + )?; + source.execute( + "INSERT INTO migration_must_not_run (marker) VALUES ('obsolete-version-gate')", + [], + )?; + Database::set_user_version(&source, version)?; dump_exact_source(&source) } @@ -754,213 +610,78 @@ fn assert_fixture_rejected( Ok(()) } -fn assert_fixture_accepted_with( - label: &str, - fixture: &str, - verify: impl Fn(&Database) -> Result<(), AppError>, -) -> Result<(), AppError> { +fn assert_full_field_restore_for_version(version: i32, label: &str) -> Result<(), AppError> { + let source = exact_source_connection(version)?; + seed_full_source_sentinels(&source, version)?; + let projections = capture_portable_sentinel_projections(&source, version)?; + let fixture = dump_exact_source(&source)?; + for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { let target = Database::memory()?; let filename = format!("{label}-{entry:?}.db").to_ascii_lowercase(); - run_restore_fixture(&target, fixture, entry, &filename).map_err(|error| { + run_restore_fixture(&target, &fixture, entry, &filename).map_err(|error| { AppError::InvalidInput(format!( - "{label} via {entry:?} must be accepted losslessly: {error}" + "full-field source v{version} failed via {entry:?}: {error}" )) })?; - verify(&target)?; + assert_portable_sentinel_projections(&target, version, entry, &projections)?; + assert_nonportable_source_rows_not_published(&target, version, entry)?; + + if version == super::SCHEMA_VERSION - 1 { + assert_v16_structural_additions(&target, entry)?; + } } Ok(()) } #[test] #[serial] -fn v1_damaged_proxy_rows_fail_closed_through_sql_and_binary_entries() -> Result<(), AppError> { +fn v1_through_v15_are_rejected_before_any_untrusted_migration_attempt() -> Result<(), AppError> { let home = tempfile::tempdir().map_err(|error| AppError::IoContext { - context: "create migration certification home".to_string(), + context: "create obsolete restore-version matrix home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(home.path()); - for damage in [ - V1Damage::MissingPrimaryRow, - V1Damage::DuplicatePrimaryRow, - V1Damage::WrongStorageClass, - V1Damage::FieldDecodeFailure, - V1Damage::MissingCircuitRow, - V1Damage::DuplicateCircuitRow, - V1Damage::MissingSettingRow, - V1Damage::DuplicateSettingRow, - ] { + for version in 1..=(super::SCHEMA_VERSION - 2) { + let fixture = obsolete_source_fixture(version)?; 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 filename = format!("obsolete-v{version}-{entry:?}.db").to_ascii_lowercase(); let error = run_restore_fixture(&target, &fixture, entry, &filename) - .expect_err("damaged v1 restore must fail closed"); + .expect_err("v1..v15 untrusted restore must be rejected at the version gate"); assert!( matches!(error, AppError::InvalidInput(_)), - "migration damage must be a structured InvalidInput, got {error:?}" + "v{version} via {entry:?} must be a structured InvalidInput, got {error:?}" + ); + let rendered = error.to_string(); + assert!( + rendered.contains(&format!("user_version={version}")) + && rendered.contains("备份版本过旧"), + "v{version} via {entry:?} must identify the source version and obsolete-backup \ + semantics, got {rendered}" ); 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 mut statement = conn.prepare( - "SELECT - app_type, proxy_enabled, listen_address, listen_port, - enable_logging, enabled, auto_failover_enabled, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, - non_streaming_timeout, circuit_failure_threshold, - circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests, - created_at, updated_at - FROM proxy_config ORDER BY app_type", - )?; - let restored = statement - .query_map([], |row| { - Ok(RestoredV1ProxyRow { - app_type: row.get(0)?, - proxy_enabled: row.get(1)?, - listen_address: row.get(2)?, - listen_port: row.get(3)?, - enable_logging: row.get(4)?, - enabled: row.get(5)?, - auto_failover_enabled: row.get(6)?, - max_retries: row.get(7)?, - streaming_first_byte_timeout: row.get(8)?, - streaming_idle_timeout: row.get(9)?, - non_streaming_timeout: row.get(10)?, - circuit_failure_threshold: row.get(11)?, - circuit_success_threshold: row.get(12)?, - circuit_timeout_seconds: row.get(13)?, - circuit_error_rate_threshold: row.get(14)?, - circuit_min_requests: row.get(15)?, - created_at: row.get(16)?, - updated_at: row.get(17)?, - }) - })? - .collect::, _>>()?; - let expected_flags = [ - ("claude", 1, 0), - ("codex", 0, 1), - ("gemini", 1, 1), - ("grokbuild", 0, 0), - ]; - assert_eq!(restored.len(), expected_flags.len()); - for (row, (app_type, enabled, auto_failover)) in restored.iter().zip(expected_flags) { - assert_eq!(row.app_type, app_type); - if app_type == "grokbuild" { - assert_eq!(row.proxy_enabled, 0); - assert_eq!(row.listen_address, "127.0.0.1"); - assert_eq!(row.listen_port, 15721); - assert_eq!(row.enable_logging, 1); - assert_eq!(row.enabled, 0); - assert_eq!(row.auto_failover_enabled, 0); - assert_eq!(row.max_retries, 3); - assert_eq!(row.streaming_first_byte_timeout, 60); - assert_eq!(row.streaming_idle_timeout, 120); - assert_eq!(row.non_streaming_timeout, 600); - assert_eq!(row.circuit_failure_threshold, 4); - assert_eq!(row.circuit_success_threshold, 2); - assert_eq!(row.circuit_timeout_seconds, 60); - assert_eq!(row.circuit_error_rate_threshold, 0.6); - assert_eq!(row.circuit_min_requests, 10); - } else { - assert_eq!(row.proxy_enabled, 1); - assert_eq!(row.listen_address, "10.20.30.40"); - assert_eq!(row.listen_port, 23456); - assert_eq!(row.enable_logging, 0); - assert_eq!(row.enabled, enabled); - assert_eq!(row.auto_failover_enabled, auto_failover); - assert_eq!(row.max_retries, 9); - assert_eq!(row.streaming_first_byte_timeout, 61); - assert_eq!(row.streaming_idle_timeout, 122); - assert_eq!(row.non_streaming_timeout, 603); - assert_eq!(row.circuit_failure_threshold, 7); - assert_eq!(row.circuit_success_threshold, 4); - assert_eq!(row.circuit_timeout_seconds, 73); - assert_eq!(row.circuit_error_rate_threshold, 0.375); - assert_eq!(row.circuit_min_requests, 21); - } - assert_eq!(row.created_at, super::schema::UNTRUSTED_MIGRATION_TIMESTAMP); - assert_eq!(row.updated_at, super::schema::UNTRUSTED_MIGRATION_TIMESTAMP); - } - let future_setting: String = conn.query_row( - "SELECT value FROM settings WHERE key = 'proxy_takeover_future'", - [], - |row| row.get(0), - )?; - assert_eq!( - future_setting, "preserve-me", - "row-selective ownership mapping must not discard future settings" - ); - } - Ok(()) -} - -#[test] -#[serial] -fn v1_restore_does_not_synthesize_current_model_pricing() -> Result<(), AppError> { - let home = tempfile::tempdir().map_err(|error| AppError::IoContext { - context: "create v1 structural-table migration 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!("v1-empty-pricing-{entry:?}.db").to_ascii_lowercase(); - run_restore_fixture(&target, &fixture, entry, &filename)?; - let conn = super::lock_conn!(target.conn); - let count: i64 = - conn.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))?; - assert_eq!( - count, 0, - "v1 via {entry:?} must construct the v2-owned table empty instead of \ - importing the current binary's pricing seed" - ); - } - Ok(()) -} - -#[test] -#[serial] -fn every_declared_source_version_reaches_public_canonical_restore() -> Result<(), AppError> { +fn every_supported_source_version_reaches_public_canonical_restore() -> Result<(), AppError> { let home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create source-spec migration matrix home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(home.path()); - for version in 1..=super::SCHEMA_VERSION { + for version in (super::SCHEMA_VERSION - 1)..=super::SCHEMA_VERSION { if version < super::SCHEMA_VERSION { validate_migration_mapping_completeness(version)?; } let source = exact_source_connection(version)?; - if version == 1 { - seed_required_v1_rows(&source)?; - } let fixture = dump_exact_source(&source)?; for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { let target = Database::memory()?; @@ -977,40 +698,93 @@ fn every_declared_source_version_reaches_public_canonical_restore() -> Result<() #[test] #[serial] -fn every_source_version_preserves_full_field_sentinels_through_both_public_entries( -) -> Result<(), AppError> { +fn v16_preupgrade_backup_restores_losslessly_through_both_public_entries() -> Result<(), AppError> { let home = tempfile::tempdir().map_err(|error| AppError::IoContext { - context: "create full-field migration matrix home".to_string(), + context: "create v16 pre-upgrade backup restore home".to_string(), source: error, })?; let _home_guard = TestHomeGuard::set(home.path()); - for version in 1..=super::SCHEMA_VERSION { - let source = exact_source_connection(version)?; - seed_full_source_sentinels(&source, version)?; - let projections = capture_portable_sentinel_projections(&source, version)?; - let fixture = dump_exact_source(&source)?; + let (source_fixture, projections) = production_v16_database_fixture()?; + let config_dir = crate::config::get_app_config_dir(); + std::fs::create_dir_all(&config_dir).map_err(|error| AppError::io(&config_dir, error))?; + write_binary_fixture(&source_fixture, &config_dir.join("cc-switch.db"))?; - for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { - let target = Database::memory()?; - let filename = format!("full-field-v{version}-{entry:?}.db").to_ascii_lowercase(); - run_restore_fixture(&target, &fixture, entry, &filename).map_err(|error| { - AppError::InvalidInput(format!( - "full-field source v{version} failed via {entry:?}: {error}" - )) - })?; - assert_portable_sentinel_projections(&target, version, entry, &projections)?; - assert_nonportable_source_rows_not_published(&target, version, entry)?; - assert_legacy_skill_mapping(&target, version, entry)?; + let upgraded = Database::init()?; + let backup_dir = config_dir.join("backups"); + let backup_paths = std::fs::read_dir(&backup_dir) + .map_err(|error| AppError::io(&backup_dir, error))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("db_backup_") && name.ends_with(".db")) + }) + .collect::>(); + let mut preupgrade_backups = Vec::new(); + for path in backup_paths { + let connection = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + if Database::get_user_version(&connection)? == super::SCHEMA_VERSION - 1 { + preupgrade_backups.push(path); } } + preupgrade_backups.sort(); + assert_eq!( + preupgrade_backups.len(), + 1, + "v16 startup must retain exactly one v16 pre-migration backup" + ); + let backup_path = preupgrade_backups.pop().expect("one pre-upgrade backup"); + let backup_filename = backup_path + .file_name() + .and_then(|name| name.to_str()) + .expect("UTF-8 backup filename") + .to_string(); + let backup_conn = Connection::open_with_flags(&backup_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + assert_eq!( + Database::get_user_version(&backup_conn)?, + super::SCHEMA_VERSION - 1, + "pre-migration backup must retain the v16 source label" + ); + Database::validate_migration_source_version(&backup_conn, super::SCHEMA_VERSION - 1)?; + let backup_sql = dump_exact_source(&backup_conn)?; + drop(backup_conn); + drop(upgraded); + + for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { + let target = Database::memory()?; + match entry { + RestoreEntry::Sql => target.import_sql_string(&backup_sql)?, + RestoreEntry::Binary => target.restore_from_backup(&backup_filename)?, + }; + assert_portable_sentinel_projections( + &target, + super::SCHEMA_VERSION - 1, + entry, + &projections, + )?; + assert_nonportable_source_rows_not_published(&target, super::SCHEMA_VERSION - 1, entry)?; + assert_v16_structural_additions(&target, entry)?; + } Ok(()) } #[test] #[serial] -fn source_spec_rejects_missing_v17_settings_and_v1_proxy_table_at_both_entries( -) -> Result<(), AppError> { +fn v17_current_backup_restores_full_fields_through_both_public_entries() -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create v17 current backup restore home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + + assert_full_field_restore_for_version(super::SCHEMA_VERSION, "v17-current-full-field") +} + +#[test] +#[serial] +fn source_spec_rejects_missing_v16_and_v17_tables_at_both_entries() -> Result<(), AppError> { let home = tempfile::tempdir().map_err(|error| AppError::IoContext { context: "create source-spec missing-table home".to_string(), source: error, @@ -1025,13 +799,12 @@ fn source_spec_rejects_missing_v17_settings_and_v1_proxy_table_at_both_entries( &dump_exact_source(&v17)?, )?; - let v1 = exact_source_connection(1)?; - seed_required_v1_rows(&v1)?; - v1.execute("DROP TABLE proxy_config", [])?; + let v16 = exact_source_connection(super::SCHEMA_VERSION - 1)?; + v16.execute("DROP TABLE providers", [])?; assert_fixture_rejected( - "v1-missing-proxy-config", + "v16-missing-providers", "source table set mismatch", - &dump_exact_source(&v1)?, + &dump_exact_source(&v16)?, ) } @@ -1044,13 +817,27 @@ fn source_spec_rejects_extra_tables_and_columns_at_both_entries() -> Result<(), })?; let _home_guard = TestHomeGuard::set(home.path()); - let extra_table = exact_source_connection(super::SCHEMA_VERSION)?; - extra_table.execute("CREATE TABLE unowned_restore_data (value TEXT)", [])?; - assert_fixture_rejected( - "v17-extra-table", - "source table set mismatch", - &dump_exact_source(&extra_table)?, - )?; + for extra_name in [ + "unowned_restore_data", + "sqliteX", + "SQLiteX", + "SQLITEx", + "sqlitefoo", + ] { + let extra_table = exact_source_connection(super::SCHEMA_VERSION)?; + extra_table.execute( + &format!( + "CREATE TABLE {} (value TEXT)", + quoted_identifier(extra_name) + ), + [], + )?; + assert_fixture_rejected( + &format!("v17-extra-table-{extra_name}"), + "source table set mismatch", + &dump_exact_source(&extra_table)?, + )?; + } let extra_column = exact_source_connection(super::SCHEMA_VERSION)?; extra_column.execute( @@ -1082,6 +869,36 @@ fn source_spec_rejects_extra_tables_and_columns_at_both_entries() -> Result<(), ) } +#[test] +#[serial] +fn hostile_dump_reserved_sqlite_prefix_is_a_structured_error() -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create reserved-prefix hostile dump home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + let source = exact_source_connection(super::SCHEMA_VERSION)?; + let hostile = format!( + "{}\nCREATE TABLE sqlite_hostile_restore_payload (value TEXT);", + dump_exact_source(&source)? + ); + let target = Database::memory()?; + seed_live_sentinel(&target)?; + let error = target + .import_sql_string(&hostile) + .expect_err("SQLite must reject a hostile dump that declares a reserved sqlite_ table"); + assert!( + matches!(error, AppError::InvalidInput(_)), + "reserved sqlite_ DDL failure must be structured, got {error:?}" + ); + assert!( + error.to_string().contains("execute untrusted SQL import"), + "reserved sqlite_ DDL must fail at SQL parsing/execution rather than disappear from the \ + source inventory: {error}" + ); + assert_live_sentinel_unchanged(&target) +} + #[test] fn untrusted_migration_cannot_invoke_the_current_schema_factory() -> Result<(), AppError> { let connection = Connection::open_in_memory()?; @@ -1096,7 +913,7 @@ fn untrusted_migration_cannot_invoke_the_current_schema_factory() -> Result<(), ); let created: i64 = connection.query_row( "SELECT COUNT(*) FROM sqlite_schema - WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + WHERE type = 'table'", [], |row| row.get(0), )?; @@ -1107,386 +924,6 @@ fn untrusted_migration_cannot_invoke_the_current_schema_factory() -> Result<(), 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( - "INSERT INTO skills (key, installed, installed_at) - VALUES ('missing-prefix', 1, 10);", - [], - )?; - Ok(()) - })?, - ), - ( - "v1-unsupported-prefixed-skill", - "unsupported app", - current_schema_fixture(1, |conn| { - conn.execute( - "INSERT INTO skills (key, installed, installed_at) - VALUES ('unknown:legacy-skill', 1, 10);", - [], - )?; - Ok(()) - })?, - ), - ( - "v1-invalid-skill-boolean", - "field installed=2", - current_schema_fixture(1, |conn| { - conn.execute( - "INSERT INTO skills (key, installed, installed_at) - VALUES ('claude:legacy-skill', 2, 10);", - [], - )?; - Ok(()) - })?, - ), - ( - "v2-unsupported-skill-app", - "unsupported app_type", - current_schema_fixture(2, |conn| { - conn.execute( - "INSERT INTO skills - (directory, app_type, installed, installed_at) - VALUES ('legacy-skill', 'unknown', 1, 10)", - [], - )?; - Ok(()) - })?, - ), - ( - "v5-invalid-provider-meta", - "provider", - current_schema_fixture(5, |conn| { - conn.execute( - "INSERT INTO providers - (id, app_type, name, settings_config, meta, is_current, - in_failover_queue, cost_multiplier) - VALUES ( - 'bad-meta', 'claude', 'bad meta', '{}', '{', 0, 0, '1.0' - )", - [], - )?; - Ok(()) - })?, - ), - ( - "v13-missing-column-fallback", - "source column set mismatch", - current_schema_fixture(13, |conn| { - conn.execute_batch("ALTER TABLE proxy_config DROP COLUMN listen_address;")?; - Ok(()) - })?, - ), - ( - "v13-wrong-storage", - "source storage mismatch", - current_schema_fixture(13, |conn| { - conn.execute( - "INSERT INTO proxy_config ( - app_type, proxy_enabled, listen_address, listen_port, - enable_logging, enabled, auto_failover_enabled, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, - non_streaming_timeout, circuit_failure_threshold, - circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests, - default_cost_multiplier, pricing_model_source, - created_at, updated_at - ) VALUES ( - 'claude', 0, '127.0.0.1', X'00', 1, 0, 0, 3, - 60, 120, 600, 4, 2, 60, 0.6, 10, - '1', 'response', 'created', 'updated' - )", - [], - )?; - Ok(()) - })?, - ), - ( - "v13-premature-grok-row", - "unsupported app_type", - current_schema_fixture(13, |conn| { - conn.execute( - "INSERT INTO proxy_config ( - app_type, proxy_enabled, listen_address, listen_port, - enable_logging, enabled, auto_failover_enabled, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, - non_streaming_timeout, circuit_failure_threshold, - circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests, - default_cost_multiplier, pricing_model_source, - created_at, updated_at - ) VALUES ( - 'grokbuild', 0, '127.0.0.1', 15721, 1, 0, 0, 3, - 60, 120, 600, 4, 2, 60, 0.6, 10, - '1', 'response', 'created', 'updated' - )", - [], - )?; - Ok(()) - })?, - ), - ]; - - for (label, expected_step, fixture) in fixtures { - assert_fixture_rejected(label, expected_step, &fixture)?; - } - Ok(()) -} - -#[test] -#[serial] -fn historical_rows_are_mapped_or_preserved_instead_of_repaired() -> Result<(), AppError> { - let home = tempfile::tempdir().map_err(|error| AppError::IoContext { - context: "create migration preservation home".to_string(), - source: error, - })?; - let _home_guard = TestHomeGuard::set(home.path()); - - let v2 = exact_source_connection(2)?; - v2.execute( - "INSERT INTO skills (directory, app_type, installed, installed_at) - VALUES ('legacy-skill', 'claude', 1, 1234)", - [], - )?; - assert_fixture_accepted_with("v2-typed-skill", &dump_exact_source(&v2)?, |target| { - let conn = super::lock_conn!(target.conn); - let restored: (String, i64, i64, i64) = conn.query_row( - "SELECT directory, enabled_claude, enabled_codex, installed_at - FROM skills WHERE id = 'claude:legacy-skill'", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - )?; - assert_eq!(restored, ("legacy-skill".to_string(), 1, 0, 1234)); - Ok(()) - })?; - - let v5 = exact_source_connection(5)?; - let copilot_meta = "{\"usage_script\":{\"enabled\":true,\"language\":\"javascript\",\ - \"code\":\"return 1\",\"template_type\":\"copilot\"}}"; - v5.execute( - "INSERT INTO providers ( - id, app_type, name, settings_config, meta, is_current, - in_failover_queue, cost_multiplier - ) VALUES ('copilot-meta', 'claude', 'copilot meta', '{}', ?1, 0, 0, '1.0')", - [copilot_meta], - )?; - assert_fixture_accepted_with("v5-meta-identity", &dump_exact_source(&v5)?, |target| { - let conn = super::lock_conn!(target.conn); - let restored: String = conn.query_row( - "SELECT meta FROM providers - WHERE id = 'copilot-meta' AND app_type = 'claude'", - [], - |row| row.get(0), - )?; - assert_eq!(restored, copilot_meta); - Ok(()) - })?; - - for (version, model_id) in [(7, "deepseek-v3.2"), (8, "untrusted-extra")] { - let source = exact_source_connection(version)?; - 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 (?1, 'portable price', '999', '37', '11', '5')", - [model_id], - )?; - assert_fixture_accepted_with( - &format!("v{version}-pricing-identity"), - &dump_exact_source(&source)?, - |target| { - let conn = super::lock_conn!(target.conn); - let restored: (String, String, String, String) = conn.query_row( - "SELECT input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, - cache_creation_cost_per_million - FROM model_pricing WHERE model_id = ?1", - [model_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - )?; - assert_eq!( - restored, - ( - "999".to_string(), - "37".to_string(), - "11".to_string(), - "5".to_string() - ) - ); - Ok(()) - }, - )?; - } - - let v10 = exact_source_connection(10)?; - v10.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-08-01', 'claude', 'legacy', 'legacy-model', - 17, 13, 101, 202, 303, 404, '12.34', 505 - )", - [], - )?; - assert_fixture_accepted_with( - "v10-rollup-total-map", - &dump_exact_source(&v10)?, - |target| { - let conn = super::lock_conn!(target.conn); - let restored: (String, String, i64, i64, i64, i64, String, i64) = conn.query_row( - "SELECT request_model, pricing_model, request_count, - success_count, input_tokens, cache_creation_tokens, - total_cost_usd, avg_latency_ms - FROM usage_daily_rollups - WHERE date = '2026-08-01' AND model = 'legacy-model'", - [], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - row.get(7)?, - )) - }, - )?; - assert_eq!( - restored, - ( - "".to_string(), - "".to_string(), - 17, - 13, - 101, - 404, - "12.34".to_string(), - 505 - ) - ); - Ok(()) - }, - )?; - - let v13 = exact_source_connection(13)?; - v13.execute( - "INSERT INTO proxy_config ( - app_type, proxy_enabled, listen_address, listen_port, - enable_logging, enabled, auto_failover_enabled, max_retries, - streaming_first_byte_timeout, streaming_idle_timeout, - non_streaming_timeout, circuit_failure_threshold, - circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests, - default_cost_multiplier, pricing_model_source, created_at, updated_at - ) VALUES ( - 'claude', 1, '10.13.0.1', 23113, 0, 1, 1, 13, - 131, 132, 133, 14, 15, 136, 0.25, 17, - '1.25', 'request', 'v13-created', 'v13-updated' - )", - [], - )?; - assert_fixture_accepted_with("v13-proxy-total-map", &dump_exact_source(&v13)?, |target| { - let conn = super::lock_conn!(target.conn); - let restored: (i64, String, i64, i64, String, String) = conn.query_row( - "SELECT proxy_enabled, listen_address, max_retries, - live_takeover_active, default_cost_multiplier, - pricing_model_source - FROM proxy_config WHERE app_type = 'claude'", - [], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - )) - }, - )?; - assert_eq!( - restored, - ( - 1, - "10.13.0.1".to_string(), - 13, - 0, - "1.25".to_string(), - "request".to_string() - ) - ); - let grokbuild: i64 = conn.query_row( - "SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'", - [], - |row| row.get(0), - )?; - assert_eq!(grokbuild, 1); - Ok(()) - })?; - - let v15 = exact_source_connection(15)?; - v15.execute( - "INSERT INTO proxy_request_logs ( - request_id, provider_id, app_type, model, request_model, - pricing_model, input_tokens, output_tokens, cache_read_tokens, - cache_creation_tokens, input_token_semantics, input_cost_usd, - output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, - total_cost_usd, latency_ms, first_token_ms, duration_ms, - status_code, error_message, session_id, provider_type, - is_streaming, cost_multiplier, created_at, data_source - ) VALUES ( - 'untrusted-codex-log', '_codex_session', 'codex', 'legacy', - 'request-legacy', 'price-legacy', 11, 12, 13, 14, 1, - '1', '2', '3', '4', '10', 15, 16, 17, 200, - NULL, 'session-15', 'portable', 0, '1.5', 18, 'codex_session' - )", - [], - )?; - assert_fixture_accepted_with( - "v15-codex-log-identity", - &dump_exact_source(&v15)?, - |target| { - let conn = super::lock_conn!(target.conn); - let restored: (String, i64, i64, String) = conn.query_row( - "SELECT request_model, input_tokens, cache_creation_tokens, - data_source - FROM proxy_request_logs - WHERE request_id = 'untrusted-codex-log'", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - )?; - assert_eq!( - restored, - ( - "request-legacy".to_string(), - 11, - 14, - "codex_session".to_string() - ) - ); - Ok(()) - }, - ) -} - #[test] fn full_field_provider_sentinel_comes_from_the_executed_pinned_pi_vector() { let oracle: serde_json::Value = serde_json::from_str(include_str!( @@ -1556,7 +993,7 @@ fn full_field_provider_sentinel_comes_from_the_executed_pinned_pi_vector() { } #[test] -fn migration_classification_and_no_swallowing_guard_cover_v1_through_v17() { +fn local_upgrade_chain_and_untrusted_version_gate_are_mechanically_separated() { let source = include_str!("schema.rs"); for from in 1..super::SCHEMA_VERSION { let to = from + 1; @@ -1601,4 +1038,51 @@ fn migration_classification_and_no_swallowing_guard_cover_v1_through_v17() { source.contains("untrusted schema migration v{version}->v{} failed"), "the migration boundary must map non-structured failures to InvalidInput" ); + + let restore = include_str!("backup.rs"); + let finish_start = restore + .find("fn finish_input(self) -> Result") + .expect("untrusted scratch finish_input exists"); + let finish_end = restore[finish_start..] + .find("fn constrain_scratch_growth") + .map(|offset| finish_start + offset) + .expect("finish_input boundary exists"); + let finish = &restore[finish_start..finish_end]; + let gate = finish + .find("require_supported_untrusted_restore_version(version)?;") + .expect("N/N-1 version gate exists"); + for later_boundary in [ + "self.drop_untrusted_executable_objects()?", + "Database::validate_untrusted_migration_source(&self.connection)?", + "Database::apply_schema_migrations_on_conn(", + ] { + let boundary = finish + .find(later_boundary) + .unwrap_or_else(|| panic!("missing restore boundary {later_boundary}")); + assert!( + gate < boundary, + "obsolete-version rejection must precede {later_boundary}" + ); + } + + let specs = include_str!("migration_source.rs"); + let database_module = include_str!("mod.rs"); + for (label, inspected) in [ + ("source specification", specs), + ("restore implementation", restore), + ("database module", database_module), + ] { + assert!( + !inspected.contains("LIKE 'sqlite_%'") && !inspected.contains("LIKE \"sqlite_%\""), + "{label} must not use '_' as a LIKE wildcard for internal-table recognition" + ); + } + assert!( + database_module.contains(r#"&["sqlite_sequence", "sqlite_stat1", "sqlite_stat4"]"#,), + "all database paths must share one exact SQLite-internal table allowlist" + ); + assert!( + !specs.contains("V1_MAPPINGS") && !specs.contains("V2_MAPPINGS"), + "historical untrusted mapping manifests must stay outside the N/N-1 source-spec module" + ); } diff --git a/src-tauri/src/database/migration_source.rs b/src-tauri/src/database/migration_source.rs index 2c88dfc4e..6393b28b1 100644 --- a/src-tauri/src/database/migration_source.rs +++ b/src-tauri/src/database/migration_source.rs @@ -1,16 +1,14 @@ -//! Declarative source schemas for untrusted database migration. +//! Declarative source schemas for N/N-1 untrusted database migration. //! //! The restore pipeline validates one of these versioned shapes before any //! migration DDL runs. This module is intentionally independent from the //! current-schema factory: source recognition must never become true because //! current tables were created into the untrusted database. //! -//! The supported shape for a version is the single adjudicated restore shape -//! declared below. Historical builds sometimes changed fresh-install DDL -//! without changing `user_version`; those ambiguous variants are intentionally -//! not inferred or repaired here. An input that does not match this authority -//! exactly fails closed and can be handled only by an explicitly versioned -//! future source spec. +//! Only the current and immediately previous schema versions are accepted. +//! Importing v1..v15 backups is a separate future “historical backup import” +//! project. Local in-place upgrades still use the complete migration chain in +//! `schema.rs`; this module only gates the untrusted SQL/binary restore paths. //! //! SQLite storage classes belong to values rather than columns. Accordingly, //! the declaration spelling (`BOOLEAN` versus `INTEGER`, for example) is not @@ -22,6 +20,7 @@ use crate::error::AppError; use rusqlite::{Connection, OptionalExtension}; use std::collections::{BTreeMap, BTreeSet}; +const EARLIEST_UNTRUSTED_RESTORE_VERSION: i32 = 16; const LATEST_DECLARED_SOURCE_VERSION: i32 = 17; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -122,26 +121,6 @@ macro_rules! table { }; } -table!( - PROVIDERS_V1, - "providers", - [ - text!("id"), - text!("app_type"), - text!("name"), - text!("settings_config"), - nullable_text!("website_url"), - nullable_text!("category"), - nullable_integer!("created_at"), - nullable_integer!("sort_index"), - nullable_text!("notes"), - nullable_text!("icon"), - nullable_text!("icon_color"), - text!("meta"), - boolean!("is_current"), - ] -); - table!( PROVIDERS_V2, "providers", @@ -168,7 +147,7 @@ table!( ); table!( - PROVIDER_ENDPOINTS_V1, + PROVIDER_ENDPOINTS_V16, "provider_endpoints", [ integer!("id"), @@ -192,60 +171,6 @@ table!( ] ); -table!( - MCP_V1, - "mcp_servers", - [ - text!("id"), - text!("name"), - text!("server_config"), - nullable_text!("description"), - nullable_text!("homepage"), - nullable_text!("docs"), - text!("tags"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - ] -); - -table!( - MCP_V4, - "mcp_servers", - [ - text!("id"), - text!("name"), - text!("server_config"), - nullable_text!("description"), - nullable_text!("homepage"), - nullable_text!("docs"), - text!("tags"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - boolean!("enabled_opencode"), - ] -); - -table!( - MCP_V10, - "mcp_servers", - [ - text!("id"), - text!("name"), - text!("server_config"), - nullable_text!("description"), - nullable_text!("homepage"), - nullable_text!("docs"), - text!("tags"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - boolean!("enabled_opencode"), - boolean!("enabled_hermes"), - ] -); - table!( MCP_V15, "mcp_servers", @@ -281,111 +206,6 @@ table!( ] ); -table!( - SKILLS_V1, - "skills", - [ - text!("key"), - boolean!("installed"), - integer!("installed_at"), - ] -); - -table!( - SKILLS_V2, - "skills", - [ - text!("directory"), - text!("app_type"), - boolean!("installed"), - integer!("installed_at"), - ] -); - -table!( - SKILLS_V3, - "skills", - [ - text!("id"), - text!("name"), - nullable_text!("description"), - text!("directory"), - nullable_text!("repo_owner"), - nullable_text!("repo_name"), - nullable_text!("repo_branch"), - nullable_text!("readme_url"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - integer!("installed_at"), - ] -); - -table!( - SKILLS_V4, - "skills", - [ - text!("id"), - text!("name"), - nullable_text!("description"), - text!("directory"), - nullable_text!("repo_owner"), - nullable_text!("repo_name"), - nullable_text!("repo_branch"), - nullable_text!("readme_url"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - boolean!("enabled_opencode"), - integer!("installed_at"), - ] -); - -table!( - SKILLS_V7, - "skills", - [ - text!("id"), - text!("name"), - nullable_text!("description"), - text!("directory"), - nullable_text!("repo_owner"), - nullable_text!("repo_name"), - nullable_text!("repo_branch"), - nullable_text!("readme_url"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - boolean!("enabled_opencode"), - integer!("installed_at"), - nullable_text!("content_hash"), - integer!("updated_at"), - ] -); - -table!( - SKILLS_V10, - "skills", - [ - text!("id"), - text!("name"), - nullable_text!("description"), - text!("directory"), - nullable_text!("repo_owner"), - nullable_text!("repo_name"), - nullable_text!("repo_branch"), - nullable_text!("readme_url"), - boolean!("enabled_claude"), - boolean!("enabled_codex"), - boolean!("enabled_gemini"), - boolean!("enabled_opencode"), - boolean!("enabled_hermes"), - integer!("installed_at"), - nullable_text!("content_hash"), - integer!("updated_at"), - ] -); - table!( SKILLS_V15, "skills", @@ -452,89 +272,6 @@ table!( [text!("key"), nullable_text!("value")] ); -// The adjudicated v1 input shape contains the last singleton proxy layout. -// Its fields are all consumed by the typed v1 fan-out mapper. -table!( - PROXY_CONFIG_V1, - "proxy_config", - [ - integer!("id"), - boolean!("proxy_enabled"), - text!("listen_address"), - integer!("listen_port"), - boolean!("enable_logging"), - integer!("max_retries"), - integer!("streaming_first_byte_timeout"), - integer!("streaming_idle_timeout"), - integer!("non_streaming_timeout"), - ] -); - -table!( - CIRCUIT_BREAKER_CONFIG_V1, - "circuit_breaker_config", - [ - integer!("id"), - integer!("failure_threshold"), - integer!("success_threshold"), - integer!("timeout_seconds"), - real!("error_rate_threshold"), - integer!("min_requests"), - ] -); - -table!( - PROXY_CONFIG_V2, - "proxy_config", - [ - text!("app_type"), - boolean!("proxy_enabled"), - text!("listen_address"), - integer!("listen_port"), - boolean!("enable_logging"), - boolean!("enabled"), - boolean!("auto_failover_enabled"), - integer!("max_retries"), - integer!("streaming_first_byte_timeout"), - integer!("streaming_idle_timeout"), - integer!("non_streaming_timeout"), - integer!("circuit_failure_threshold"), - integer!("circuit_success_threshold"), - integer!("circuit_timeout_seconds"), - real!("circuit_error_rate_threshold"), - integer!("circuit_min_requests"), - text!("created_at"), - text!("updated_at"), - ] -); - -table!( - PROXY_CONFIG_V5, - "proxy_config", - [ - text!("app_type"), - boolean!("proxy_enabled"), - text!("listen_address"), - integer!("listen_port"), - boolean!("enable_logging"), - boolean!("enabled"), - boolean!("auto_failover_enabled"), - integer!("max_retries"), - integer!("streaming_first_byte_timeout"), - integer!("streaming_idle_timeout"), - integer!("non_streaming_timeout"), - integer!("circuit_failure_threshold"), - integer!("circuit_success_threshold"), - integer!("circuit_timeout_seconds"), - real!("circuit_error_rate_threshold"), - integer!("circuit_min_requests"), - text!("created_at"), - text!("updated_at"), - text!("default_cost_multiplier"), - text!("pricing_model_source"), - ] -); - table!( PROXY_CONFIG_V14, "proxy_config", @@ -578,132 +315,6 @@ table!( ] ); -table!( - PROXY_LOG_V2, - "proxy_request_logs", - [ - text!("request_id"), - text!("provider_id"), - text!("app_type"), - text!("model"), - integer!("input_tokens"), - integer!("output_tokens"), - integer!("cache_read_tokens"), - integer!("cache_creation_tokens"), - text!("input_cost_usd"), - text!("output_cost_usd"), - text!("cache_read_cost_usd"), - text!("cache_creation_cost_usd"), - text!("total_cost_usd"), - integer!("latency_ms"), - nullable_integer!("first_token_ms"), - nullable_integer!("duration_ms"), - integer!("status_code"), - nullable_text!("error_message"), - nullable_text!("session_id"), - nullable_text!("provider_type"), - boolean!("is_streaming"), - text!("cost_multiplier"), - integer!("created_at"), - ] -); - -table!( - PROXY_LOG_V5, - "proxy_request_logs", - [ - text!("request_id"), - text!("provider_id"), - text!("app_type"), - text!("model"), - nullable_text!("request_model"), - integer!("input_tokens"), - integer!("output_tokens"), - integer!("cache_read_tokens"), - integer!("cache_creation_tokens"), - text!("input_cost_usd"), - text!("output_cost_usd"), - text!("cache_read_cost_usd"), - text!("cache_creation_cost_usd"), - text!("total_cost_usd"), - integer!("latency_ms"), - nullable_integer!("first_token_ms"), - nullable_integer!("duration_ms"), - integer!("status_code"), - nullable_text!("error_message"), - nullable_text!("session_id"), - nullable_text!("provider_type"), - boolean!("is_streaming"), - text!("cost_multiplier"), - integer!("created_at"), - ] -); - -table!( - PROXY_LOG_V8, - "proxy_request_logs", - [ - text!("request_id"), - text!("provider_id"), - text!("app_type"), - text!("model"), - nullable_text!("request_model"), - integer!("input_tokens"), - integer!("output_tokens"), - integer!("cache_read_tokens"), - integer!("cache_creation_tokens"), - text!("input_cost_usd"), - text!("output_cost_usd"), - text!("cache_read_cost_usd"), - text!("cache_creation_cost_usd"), - text!("total_cost_usd"), - integer!("latency_ms"), - nullable_integer!("first_token_ms"), - nullable_integer!("duration_ms"), - integer!("status_code"), - nullable_text!("error_message"), - nullable_text!("session_id"), - nullable_text!("provider_type"), - boolean!("is_streaming"), - text!("cost_multiplier"), - integer!("created_at"), - text!("data_source"), - ] -); - -table!( - PROXY_LOG_V11, - "proxy_request_logs", - [ - text!("request_id"), - text!("provider_id"), - text!("app_type"), - text!("model"), - nullable_text!("request_model"), - nullable_text!("pricing_model"), - integer!("input_tokens"), - integer!("output_tokens"), - integer!("cache_read_tokens"), - integer!("cache_creation_tokens"), - text!("input_cost_usd"), - text!("output_cost_usd"), - text!("cache_read_cost_usd"), - text!("cache_creation_cost_usd"), - text!("total_cost_usd"), - integer!("latency_ms"), - nullable_integer!("first_token_ms"), - nullable_integer!("duration_ms"), - integer!("status_code"), - nullable_text!("error_message"), - nullable_text!("session_id"), - nullable_text!("provider_type"), - boolean!("is_streaming"), - text!("cost_multiplier"), - integer!("created_at"), - text!("data_source"), - ] -); - table!( PROXY_LOG_V13, "proxy_request_logs", @@ -780,46 +391,6 @@ table!( ] ); -table!( - USAGE_ROLLUPS_V6, - "usage_daily_rollups", - [ - text!("date"), - text!("app_type"), - text!("provider_id"), - text!("model"), - integer!("request_count"), - integer!("success_count"), - integer!("input_tokens"), - integer!("output_tokens"), - integer!("cache_read_tokens"), - integer!("cache_creation_tokens"), - text!("total_cost_usd"), - integer!("avg_latency_ms"), - ] -); - -table!( - USAGE_ROLLUPS_V11, - "usage_daily_rollups", - [ - text!("date"), - text!("app_type"), - text!("provider_id"), - text!("model"), - text!("request_model"), - text!("pricing_model"), - integer!("request_count"), - integer!("success_count"), - integer!("input_tokens"), - integer!("output_tokens"), - integer!("cache_read_tokens"), - integer!("cache_creation_tokens"), - text!("total_cost_usd"), - integer!("avg_latency_ms"), - ] -); - table!( USAGE_ROLLUPS_V13, "usage_daily_rollups", @@ -907,93 +478,55 @@ fn replace_table( } } -fn remove_table(tables: &mut Vec<&'static MigrationSourceTableSpec>, name: &str) { - tables.retain(|table| table.name != name); -} - -pub(crate) fn migration_source_spec(version: i32) -> Result { +fn ensure_source_specs_match_current_schema() -> Result<(), AppError> { if SCHEMA_VERSION != LATEST_DECLARED_SOURCE_VERSION { return Err(AppError::Config(format!( "schema version {SCHEMA_VERSION} has no reviewed MigrationSourceSpec; \ latest declared source version is {LATEST_DECLARED_SOURCE_VERSION}" ))); } - if !(1..=LATEST_DECLARED_SOURCE_VERSION).contains(&version) { + Ok(()) +} + +pub(crate) fn require_supported_untrusted_restore_version(version: i32) -> Result<(), AppError> { + ensure_source_specs_match_current_schema()?; + if version < EARLIEST_UNTRUSTED_RESTORE_VERSION { return Err(AppError::InvalidInput(format!( - "unsupported restore user_version {version}; supported versions are \ - 1..={LATEST_DECLARED_SOURCE_VERSION}" + "备份版本过旧: source user_version={version}; untrusted restore accepts only \ + user_version {EARLIEST_UNTRUSTED_RESTORE_VERSION} or \ + {LATEST_DECLARED_SOURCE_VERSION}" ))); } + if version > LATEST_DECLARED_SOURCE_VERSION { + return Err(AppError::InvalidInput(format!( + "restore source user_version={version} is newer than supported \ + user_version {LATEST_DECLARED_SOURCE_VERSION}" + ))); + } + Ok(()) +} +pub(crate) fn migration_source_spec(version: i32) -> Result { + require_supported_untrusted_restore_version(version)?; let mut tables = vec![ - &PROVIDERS_V1, - &PROVIDER_ENDPOINTS_V1, - &MCP_V1, + &PROVIDERS_V2, + &PROVIDER_ENDPOINTS_V16, + &MCP_V15, &PROMPTS, - &SKILLS_V1, + &SKILLS_V15, &SKILL_REPOS, &SETTINGS, - &PROXY_CONFIG_V1, - &CIRCUIT_BREAKER_CONFIG_V1, + &PROXY_CONFIG_V14, + &PROVIDER_HEALTH, + &PROXY_LOG_V13, + &MODEL_PRICING, + &STREAM_CHECK_LOGS, + &PROXY_LIVE_BACKUP, + &USAGE_ROLLUPS_V13, + &SESSION_LOG_SYNC, + &PROFILES, ]; - - if version >= 2 { - replace_table(&mut tables, &PROVIDERS_V2); - replace_table(&mut tables, &SKILLS_V2); - replace_table(&mut tables, &PROXY_CONFIG_V2); - remove_table(&mut tables, CIRCUIT_BREAKER_CONFIG_V1.name); - tables.extend([ - &PROVIDER_HEALTH, - &PROXY_LOG_V2, - &MODEL_PRICING, - &STREAM_CHECK_LOGS, - &PROXY_LIVE_BACKUP, - ]); - } - if version >= 3 { - replace_table(&mut tables, &SKILLS_V3); - } - if version >= 4 { - replace_table(&mut tables, &MCP_V4); - replace_table(&mut tables, &SKILLS_V4); - } - if version >= 5 { - replace_table(&mut tables, &PROXY_CONFIG_V5); - replace_table(&mut tables, &PROXY_LOG_V5); - } - if version >= 6 { - tables.push(&USAGE_ROLLUPS_V6); - } - if version >= 7 { - replace_table(&mut tables, &SKILLS_V7); - } - if version >= 8 { - replace_table(&mut tables, &PROXY_LOG_V8); - tables.push(&SESSION_LOG_SYNC); - } - if version >= 10 { - replace_table(&mut tables, &MCP_V10); - replace_table(&mut tables, &SKILLS_V10); - } - if version >= 11 { - replace_table(&mut tables, &PROXY_LOG_V11); - replace_table(&mut tables, &USAGE_ROLLUPS_V11); - } - if version >= 12 { - tables.push(&PROFILES); - } - if version >= 13 { - replace_table(&mut tables, &PROXY_LOG_V13); - replace_table(&mut tables, &USAGE_ROLLUPS_V13); - } - if version >= 14 { - replace_table(&mut tables, &PROXY_CONFIG_V14); - } - if version >= 15 { - replace_table(&mut tables, &MCP_V15); - replace_table(&mut tables, &SKILLS_V15); - } - if version >= 17 { + if version == LATEST_DECLARED_SOURCE_VERSION { replace_table(&mut tables, &PROVIDER_ENDPOINTS_V17); replace_table(&mut tables, &SKILLS_V17); tables.extend([&PI_PROVIDER_PROJECTIONS, &SKILL_DEPLOYMENTS]); @@ -1054,524 +587,84 @@ pub(crate) fn pinned_pi_provider_settings_for_test(version: i32) -> String { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct MigrationTargetColumn { - pub(crate) table: &'static str, - pub(crate) column: &'static str, - pub(crate) storage: MigrationStorageClass, +struct MigrationStructuralColumnAddition { + table: &'static str, + column: &'static str, + storage: MigrationStorageClass, + nullable: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MigrationColumnDisposition { - Derived(&'static [MigrationTargetColumn]), - FanOutDerived { - targets: &'static [MigrationTargetColumn], - rule: &'static str, - }, - /// One source value is copied to the same-named column on every row - /// produced by a declared fan-out. - FanOutIdentity { - target_table: &'static str, - rule: &'static str, - }, - /// Every source value remains in the same-named identity column, while - /// rows selected by `rule` additionally contribute to `derived`. - IdentityAndDerived { - identity_table: &'static str, - derived: &'static [MigrationTargetColumn], - rule: &'static str, - }, - Structural(&'static str), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct MigrationColumnMapping { - pub(crate) source_table: &'static str, - pub(crate) source_column: &'static str, - pub(crate) disposition: MigrationColumnDisposition, -} - -const V1_KEY_TARGETS: &[MigrationTargetColumn] = &[ - MigrationTargetColumn { - table: "skills", - column: "directory", - storage: MigrationStorageClass::Text, - }, - MigrationTargetColumn { - table: "skills", - column: "app_type", - storage: MigrationStorageClass::Text, - }, -]; -const V1_OWNERSHIP_KEY_TARGETS: &[MigrationTargetColumn] = &[ - MigrationTargetColumn { - table: "proxy_config", - column: "app_type", - storage: MigrationStorageClass::Text, - }, - MigrationTargetColumn { - table: "proxy_config", - column: "enabled", +const V16_TO_V17_STRUCTURAL_COLUMNS: &[MigrationStructuralColumnAddition] = &[ + MigrationStructuralColumnAddition { + table: "provider_endpoints", + column: "last_used", storage: MigrationStorageClass::Integer, + nullable: true, }, - MigrationTargetColumn { - table: "proxy_config", - column: "auto_failover_enabled", + MigrationStructuralColumnAddition { + table: "skills", + column: "enabled_pi", storage: MigrationStorageClass::Integer, - }, -]; -const V1_OWNERSHIP_VALUE_TARGETS: &[MigrationTargetColumn] = &[ - MigrationTargetColumn { - table: "proxy_config", - column: "enabled", - storage: MigrationStorageClass::Integer, - }, - MigrationTargetColumn { - table: "proxy_config", - column: "auto_failover_enabled", - storage: MigrationStorageClass::Integer, - }, -]; -const V1_CIRCUIT_FAILURE_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { - table: "proxy_config", - column: "circuit_failure_threshold", - storage: MigrationStorageClass::Integer, -}]; -const V1_CIRCUIT_SUCCESS_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { - table: "proxy_config", - column: "circuit_success_threshold", - storage: MigrationStorageClass::Integer, -}]; -const V1_CIRCUIT_TIMEOUT_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { - table: "proxy_config", - column: "circuit_timeout_seconds", - storage: MigrationStorageClass::Integer, -}]; -const V1_CIRCUIT_RATE_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { - table: "proxy_config", - column: "circuit_error_rate_threshold", - storage: MigrationStorageClass::Real, -}]; -const V1_CIRCUIT_REQUESTS_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { - table: "proxy_config", - column: "circuit_min_requests", - storage: MigrationStorageClass::Integer, -}]; -const V2_APP_TARGETS: &[MigrationTargetColumn] = &[ - MigrationTargetColumn { - table: "skills", - column: "id", - storage: MigrationStorageClass::Text, - }, - MigrationTargetColumn { - table: "skills", - column: "enabled_claude", - storage: MigrationStorageClass::Integer, - }, - MigrationTargetColumn { - table: "skills", - column: "enabled_codex", - storage: MigrationStorageClass::Integer, - }, - MigrationTargetColumn { - table: "skills", - column: "enabled_gemini", - storage: MigrationStorageClass::Integer, - }, -]; -const V2_DIRECTORY_TARGETS: &[MigrationTargetColumn] = &[ - MigrationTargetColumn { - table: "skills", - column: "id", - storage: MigrationStorageClass::Text, - }, - MigrationTargetColumn { - table: "skills", - column: "name", - storage: MigrationStorageClass::Text, - }, - MigrationTargetColumn { - table: "skills", - column: "directory", - storage: MigrationStorageClass::Text, + nullable: false, }, ]; -const V1_MAPPINGS: &[MigrationColumnMapping] = &[ - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "id", - disposition: MigrationColumnDisposition::Structural( - "the singleton id=1 is the fan-out discriminator", - ), - }, - MigrationColumnMapping { - source_table: "circuit_breaker_config", - source_column: "id", - disposition: MigrationColumnDisposition::Structural( - "the singleton id=1 identifies the circuit source row", - ), - }, - MigrationColumnMapping { - source_table: "circuit_breaker_config", - source_column: "failure_threshold", - disposition: MigrationColumnDisposition::FanOutDerived { - targets: V1_CIRCUIT_FAILURE_TARGET, - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "circuit_breaker_config", - source_column: "success_threshold", - disposition: MigrationColumnDisposition::FanOutDerived { - targets: V1_CIRCUIT_SUCCESS_TARGET, - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "circuit_breaker_config", - source_column: "timeout_seconds", - disposition: MigrationColumnDisposition::FanOutDerived { - targets: V1_CIRCUIT_TIMEOUT_TARGET, - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "circuit_breaker_config", - source_column: "error_rate_threshold", - disposition: MigrationColumnDisposition::FanOutDerived { - targets: V1_CIRCUIT_RATE_TARGET, - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "circuit_breaker_config", - source_column: "min_requests", - disposition: MigrationColumnDisposition::FanOutDerived { - targets: V1_CIRCUIT_REQUESTS_TARGET, - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "skills", - source_column: "key", - disposition: MigrationColumnDisposition::Derived(V1_KEY_TARGETS), - }, - MigrationColumnMapping { - source_table: "settings", - source_column: "key", - disposition: MigrationColumnDisposition::IdentityAndDerived { - identity_table: "settings", - derived: V1_OWNERSHIP_KEY_TARGETS, - rule: "all keys survive; six exact ownership keys also select app and destination flag", - }, - }, - MigrationColumnMapping { - source_table: "settings", - source_column: "value", - disposition: MigrationColumnDisposition::IdentityAndDerived { - identity_table: "settings", - derived: V1_OWNERSHIP_VALUE_TARGETS, - rule: "all values survive; six exact ownership values also decode as booleans", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "proxy_enabled", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "listen_address", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "listen_port", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "enable_logging", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "max_retries", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "streaming_first_byte_timeout", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "streaming_idle_timeout", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, - MigrationColumnMapping { - source_table: "proxy_config", - source_column: "non_streaming_timeout", - disposition: MigrationColumnDisposition::FanOutIdentity { - target_table: "proxy_config", - rule: "copy the singleton value to the three app rows owned by v2", - }, - }, -]; - -const V2_MAPPINGS: &[MigrationColumnMapping] = &[ - MigrationColumnMapping { - source_table: "skills", - source_column: "directory", - disposition: MigrationColumnDisposition::Derived(V2_DIRECTORY_TARGETS), - }, - MigrationColumnMapping { - source_table: "skills", - source_column: "app_type", - disposition: MigrationColumnDisposition::Derived(V2_APP_TARGETS), - }, - MigrationColumnMapping { - source_table: "skills", - source_column: "installed", - disposition: MigrationColumnDisposition::Derived(V2_APP_TARGETS), - }, -]; - -fn explicit_mappings(version: i32) -> &'static [MigrationColumnMapping] { - match version { - 1 => V1_MAPPINGS, - 2 => V2_MAPPINGS, - _ => &[], - } -} - -fn validate_mapping_target( - version: i32, - mapping: &MigrationColumnMapping, - target_tables: &BTreeMap<&'static str, &'static MigrationSourceTableSpec>, - mapped_target: &MigrationTargetColumn, -) -> Result<(), AppError> { - let target_table = target_tables.get(mapped_target.table).ok_or_else(|| { - AppError::Config(format!( - "v{version} mapping targets missing table {}", - mapped_target.table - )) - })?; - let target_column = target_table - .columns - .iter() - .find(|column| column.name == mapped_target.column) - .ok_or_else(|| { - AppError::Config(format!( - "v{version} mapping targets missing column {}.{}", - mapped_target.table, mapped_target.column - )) - })?; - if target_column.storage != mapped_target.storage { - return Err(AppError::Config(format!( - "v{version} mapping for {}.{} targets {}.{} with storage {:?}, expected {:?}", - mapping.source_table, - mapping.source_column, - mapped_target.table, - mapped_target.column, - target_column.storage, - mapped_target.storage - ))); - } - Ok(()) -} - -fn validate_identity_mapping_target( - version: i32, - mapping: &MigrationColumnMapping, - source_column: &MigrationSourceColumnSpec, - target_tables: &BTreeMap<&'static str, &'static MigrationSourceTableSpec>, - target_table_name: &str, -) -> Result<(), AppError> { - let target_table = target_tables.get(target_table_name).ok_or_else(|| { - AppError::Config(format!( - "v{version} mapping targets missing identity table {target_table_name}" - )) - })?; - let target_column = target_table - .columns - .iter() - .find(|column| column.name == mapping.source_column) - .ok_or_else(|| { - AppError::Config(format!( - "v{version} mapping targets missing identity column {}.{}", - target_table_name, mapping.source_column - )) - })?; - if target_column.storage != source_column.storage { - return Err(AppError::Config(format!( - "v{version} identity mapping target {}.{} has storage {:?}, expected {:?}", - target_table_name, mapping.source_column, target_column.storage, source_column.storage - ))); - } - Ok(()) -} +const V16_TO_V17_EMPTY_STRUCTURAL_TABLES: &[&str] = + &["pi_provider_projections", "skill_deployments"]; +/// Mechanically prove that the only accepted migration consumes every v16 +/// source column and introduces no undeclared v17 shape. +/// +/// Common columns are identity mappings: SQLite storage class must remain +/// unchanged, and a nullable source may never map into a non-null target. +/// Target-only columns and tables are version-owned structural additions listed +/// above; the behavior suite separately proves their NULL/false/empty values. pub(crate) fn validate_migration_mapping_completeness(version: i32) -> Result<(), AppError> { - if version >= SCHEMA_VERSION { + require_supported_untrusted_restore_version(version)?; + if version == SCHEMA_VERSION { return Ok(()); } + if version != EARLIEST_UNTRUSTED_RESTORE_VERSION { + return Err(AppError::Config(format!( + "untrusted migration completeness is defined only for v{}->v{}; got v{version}", + EARLIEST_UNTRUSTED_RESTORE_VERSION, LATEST_DECLARED_SOURCE_VERSION + ))); + } + let source = migration_source_spec(version)?; let target = migration_source_spec(version + 1)?; + let source_tables: BTreeMap<_, _> = source + .tables + .iter() + .map(|table| (table.name, *table)) + .collect(); let target_tables: BTreeMap<_, _> = target .tables .iter() .map(|table| (table.name, *table)) .collect(); - let mappings = explicit_mappings(version); - let mut mapped_sources = BTreeSet::new(); - - for mapping in mappings { - if !mapped_sources.insert((mapping.source_table, mapping.source_column)) { - return Err(AppError::Config(format!( - "v{version} declares duplicate mappings for {}.{}", - mapping.source_table, mapping.source_column - ))); - } - let source_table = source - .tables - .iter() - .find(|table| table.name == mapping.source_table) - .ok_or_else(|| { - AppError::Config(format!( - "v{version} mapping references missing source table {}", - mapping.source_table - )) - })?; - let source_column = source_table - .columns - .iter() - .find(|column| column.name == mapping.source_column) - .ok_or_else(|| { - AppError::Config(format!( - "v{version} mapping references missing source column {}.{}", - mapping.source_table, mapping.source_column - )) - })?; - let validate_targets = |targets: &[MigrationTargetColumn]| -> Result<(), AppError> { - if targets.is_empty() { - return Err(AppError::Config(format!( - "v{version} mapping for {}.{} has no derived target", - mapping.source_table, mapping.source_column - ))); - } - for target in targets { - validate_mapping_target(version, mapping, &target_tables, target)?; - } - Ok(()) - }; - match mapping.disposition { - MigrationColumnDisposition::Derived(targets) => validate_targets(targets)?, - MigrationColumnDisposition::FanOutDerived { targets, rule } => { - if rule.is_empty() { - return Err(AppError::Config(format!( - "v{version} fan-out mapping for {}.{} has no rule", - mapping.source_table, mapping.source_column - ))); - } - validate_targets(targets)?; - } - MigrationColumnDisposition::FanOutIdentity { target_table, rule } => { - if rule.is_empty() { - return Err(AppError::Config(format!( - "v{version} fan-out mapping for {}.{} has no rule", - mapping.source_table, mapping.source_column - ))); - } - validate_identity_mapping_target( - version, - mapping, - source_column, - &target_tables, - target_table, - )?; - } - MigrationColumnDisposition::IdentityAndDerived { - identity_table, - derived, - rule, - } => { - if rule.is_empty() { - return Err(AppError::Config(format!( - "v{version} identity-and-derived mapping for {}.{} has no rule", - mapping.source_table, mapping.source_column - ))); - } - validate_identity_mapping_target( - version, - mapping, - source_column, - &target_tables, - identity_table, - )?; - validate_targets(derived)?; - } - MigrationColumnDisposition::Structural(rule) => { - if rule.is_empty() { - return Err(AppError::Config(format!( - "v{version} structural mapping for {}.{} has no rule", - mapping.source_table, mapping.source_column - ))); - } - } - } - } for source_table in &source.tables { + let target_table = target_tables.get(source_table.name).ok_or_else(|| { + AppError::Config(format!( + "v{version}->v{} drops source table {}", + version + 1, + source_table.name + )) + })?; for source_column in source_table.columns { - let explicit = mappings.iter().find(|mapping| { - mapping.source_table == source_table.name - && mapping.source_column == source_column.name - }); - if explicit.is_some() { - continue; - } - let Some(target_table) = target_tables.get(source_table.name) else { - return Err(AppError::Config(format!( - "v{version}->v{} does not consume table {} column {}", - version + 1, - source_table.name, - source_column.name - ))); - }; - let Some(target_column) = target_table + let target_column = target_table .columns .iter() .find(|column| column.name == source_column.name) - else { - return Err(AppError::Config(format!( - "v{version}->v{} does not consume column {}.{}", - version + 1, - source_table.name, - source_column.name - ))); - }; + .ok_or_else(|| { + AppError::Config(format!( + "v{version}->v{} does not consume source column {}.{}", + version + 1, + source_table.name, + source_column.name + )) + })?; if target_column.storage != source_column.storage { return Err(AppError::Config(format!( "v{version}->v{} changes storage for identity column {}.{}", @@ -1580,8 +673,95 @@ pub(crate) fn validate_migration_mapping_completeness(version: i32) -> Result<() source_column.name ))); } + if source_column.nullable && !target_column.nullable { + return Err(AppError::Config(format!( + "v{version}->v{} narrows NULL semantics for identity column {}.{}", + version + 1, + source_table.name, + source_column.name + ))); + } } } + + for target_table in &target.tables { + let Some(source_table) = source_tables.get(target_table.name) else { + if !V16_TO_V17_EMPTY_STRUCTURAL_TABLES.contains(&target_table.name) { + return Err(AppError::Config(format!( + "v{version}->v{} introduces undeclared structural table {}", + version + 1, + target_table.name + ))); + } + continue; + }; + + for target_column in target_table.columns { + if source_table + .columns + .iter() + .any(|column| column.name == target_column.name) + { + continue; + } + let addition = V16_TO_V17_STRUCTURAL_COLUMNS + .iter() + .find(|addition| { + addition.table == target_table.name && addition.column == target_column.name + }) + .ok_or_else(|| { + AppError::Config(format!( + "v{version}->v{} introduces undeclared structural column {}.{}", + version + 1, + target_table.name, + target_column.name + )) + })?; + if addition.storage != target_column.storage + || addition.nullable != target_column.nullable + { + return Err(AppError::Config(format!( + "v{version}->v{} structural column {}.{} does not match its declaration", + version + 1, + target_table.name, + target_column.name + ))); + } + } + } + + for addition in V16_TO_V17_STRUCTURAL_COLUMNS { + let source_has_column = source_tables.get(addition.table).is_some_and(|table| { + table + .columns + .iter() + .any(|column| column.name == addition.column) + }); + let target_has_column = target_tables.get(addition.table).is_some_and(|table| { + table.columns.iter().any(|column| { + column.name == addition.column + && column.storage == addition.storage + && column.nullable == addition.nullable + }) + }); + if source_has_column || !target_has_column { + return Err(AppError::Config(format!( + "v{version}->v{} structural column declaration is stale: {}.{}", + version + 1, + addition.table, + addition.column + ))); + } + } + for table in V16_TO_V17_EMPTY_STRUCTURAL_TABLES { + if source_tables.contains_key(table) || !target_tables.contains_key(table) { + return Err(AppError::Config(format!( + "v{version}->v{} structural table declaration is stale: {table}", + version + 1 + ))); + } + } + Ok(()) } @@ -1592,9 +772,10 @@ fn quoted_identifier(identifier: &str) -> String { impl Database { pub(crate) fn validate_untrusted_migration_source(conn: &Connection) -> Result { let version = Self::get_user_version(conn)?; + require_supported_untrusted_restore_version(version)?; let spec = migration_source_spec(version)?; - for mapped_version in 1..SCHEMA_VERSION { - validate_migration_mapping_completeness(mapped_version)?; + if version == EARLIEST_UNTRUSTED_RESTORE_VERSION { + validate_migration_mapping_completeness(version)?; } Self::validate_migration_source_spec(conn, &spec)?; Ok(version) @@ -1612,10 +793,15 @@ impl Database { conn: &Connection, spec: &MigrationSourceSpec, ) -> Result<(), AppError> { + // Never express the internal-table boundary with SQL LIKE: `_` is a + // wildcard there, so `sqliteX` would be mistaken for `sqlite_*`. + // Enumerating the internal tables that this SQLite build may own keeps + // every other table—including case variants and forged reserved names— + // inside the exact source inventory. let observed_tables = conn .prepare( "SELECT name FROM main.sqlite_schema - WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + WHERE type = 'table' ORDER BY name", ) .and_then(|mut statement| { @@ -1628,7 +814,10 @@ impl Database { "inspect untrusted v{} table set: {error}", spec.version )) - })?; + })? + .into_iter() + .filter(|name| !super::is_sqlite_internal_table_name(name)) + .collect::>(); let expected_tables: Vec<_> = spec .tables .iter() diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 36962a620..532cfef43 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -96,6 +96,12 @@ pub struct Database { static LIVE_DATABASE_WRITERS: OnceLock>> = OnceLock::new(); +const SQLITE_INTERNAL_TABLE_NAMES: &[&str] = &["sqlite_sequence", "sqlite_stat1", "sqlite_stat4"]; + +pub(crate) fn is_sqlite_internal_table_name(name: &str) -> bool { + SQLITE_INTERNAL_TABLE_NAMES.contains(&name) +} + struct LiveDatabaseWriteLease { identity: PathBuf, } @@ -207,9 +213,11 @@ impl Database { conn: Mutex::new(conn), _live_write_lease: Some(live_write_lease), }; - db.create_tables()?; - // Pre-migration backup: only when upgrading from an existing database + // Pre-migration backup: only when upgrading from an existing database. + // This must precede the current-schema factory. Otherwise a v16 file + // would be backed up after v17-only tables had been synthesized, making + // the nominal v16 rollback image fail its exact source specification. { let conn = lock_conn!(db.conn); let version = Self::get_user_version(&conn)?; @@ -224,6 +232,7 @@ impl Database { } } + db.create_tables()?; db.apply_schema_migrations()?; if let Err(e) = db.ensure_incremental_auto_vacuum() { log::warn!("Failed to ensure incremental auto-vacuum: {e}"); @@ -300,14 +309,19 @@ impl Database { } fn has_user_tables(conn: &Connection) -> Result { - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", - [], - |row| row.get(0), - ) + let mut statement = conn + .prepare("SELECT name FROM sqlite_schema WHERE type = 'table'") .map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?; - Ok(count > 0) + let names = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?; + for name in names { + let name = name.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?; + if !is_sqlite_internal_table_name(&name) { + return Ok(true); + } + } + Ok(false) } pub(crate) fn ensure_incremental_auto_vacuum_on_conn( diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index e4f3c9bd8..682efa2c5 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -26,20 +26,25 @@ impl CanonicalStage { } } -/// Selects the trusted local-upgrade path or the construction-only untrusted +/// Selects the trusted local-upgrade path or the construction-only N/N-1 /// restore path. /// /// # Historical migration classification (blind-review authority) /// -/// Before this table is consulted, the untrusted database must exactly match -/// the `MigrationSourceSpec` for its declared `user_version`: table set, -/// column set, and every populated SQLite storage class. No current-schema -/// table is created during recognition. Each step is then checked mechanically -/// by `validate_migration_mapping_completeness`: every source column is either -/// an identity mapping with the same storage class or an explicit typed -/// disposition. The complete target source-spec is revalidated after every -/// step. Only after the chain reaches v17 may a separately constructed -/// canonical stage be created and populated. +/// LocalUpgrade retains the complete v1→v17 in-place chain below. The untrusted +/// SQL/binary entries are narrower: `UntrustedScratch` rejects v1..v15 before +/// this dispatcher, so only v16→v17 is reachable in `UntrustedRestore`. +/// +/// A supported untrusted database must exactly match the v16 or v17 +/// `MigrationSourceSpec`: table set, column set, and every populated SQLite +/// storage class. No current-schema table is created during recognition. The +/// v16→v17 step is checked mechanically by +/// `validate_migration_mapping_completeness`, and its complete v17 target spec +/// is revalidated before a separately constructed canonical stage is populated. +/// +/// Rows v1→v2 through v15→v16 document and protect LocalUpgrade behavior and +/// preserve design groundwork for a future “historical backup import” project; +/// they are not accepted untrusted restore paths. /// /// “Total shape map” means all source values are preserved under that declared /// mapping and all added values are version-owned structural sentinels. @@ -48,7 +53,7 @@ impl CanonicalStage { /// `InvalidInput`. Local repair behavior is never reachable from /// `UntrustedRestore`. /// -/// | Step | Class | `UntrustedRestore` construction and basis | +/// | Step | Class | Transform and preservation basis | /// | --- | --- | --- | /// | v1→v2 | Typed transform + total shape map | The singleton proxy and circuit rows require `id=1`; every field fans out unchanged to the three app rows owned by v2, six ownership settings remain byte-preserved while also producing explicit app flags, and prefixed skill keys map to typed `(directory, app_type)` rows. Missing/malformed rows, unsupported keys, booleans, or numeric domains abort. All settings and identity columns survive; new timestamps use a fixed structural sentinel and v2-owned empty tables are created without canonical seed data. | /// | v2→v3 | Typed transform | Every skill row decodes as `(directory, app_type, installed, installed_at)` and deterministically produces id/name/directory/app enablement; empty directories, unsupported apps, invalid booleans, or collisions abort. No row is deferred to a filesystem rescan. |