diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index e59f79d66..1fb66aaf8 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -7,7 +7,7 @@ use super::{lock_conn, Database, SCHEMA_VERSION}; use crate::config::get_app_config_dir; use crate::error::AppError; use chrono::{Local, Utc}; -use rusqlite::backup::Backup; +use rusqlite::backup::{Backup, StepResult}; use rusqlite::config::DbConfig; use rusqlite::limits::Limit; use rusqlite::types::{Value, ValueRef}; @@ -17,6 +17,7 @@ use std::io::{Read, Take, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Duration; use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; @@ -28,6 +29,10 @@ const MAX_SQL_VALUE_BYTES: i32 = 64 * 1024 * 1024; const MAX_VM_STEPS: u64 = 50_000_000; const PROGRESS_GRANULARITY: u64 = 1_000; const MAX_PAGE_COUNT: u64 = 524_288; +const BACKUP_PAGES_PER_STEP: i32 = 256; +const MAX_BACKUP_TRANSIENT_RETRIES: u32 = 100; +const MAX_BACKUP_STEPS: u32 = 100_000; +const BACKUP_RETRY_DELAY: Duration = Duration::from_millis(10); #[cfg(test)] thread_local! { @@ -35,6 +40,8 @@ thread_local! { const { std::cell::Cell::new(None) }; static TEST_MAX_PAGE_COUNT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + static TEST_MAX_BACKUP_TRANSIENT_RETRIES: std::cell::Cell> = + const { std::cell::Cell::new(None) }; static TEST_AFTER_SAFETY_BACKUP: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; @@ -56,6 +63,14 @@ fn max_page_count() -> u64 { MAX_PAGE_COUNT } +fn max_backup_transient_retries() -> u32 { + #[cfg(test)] + if let Some(limit) = TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(std::cell::Cell::get) { + return limit; + } + MAX_BACKUP_TRANSIENT_RETRIES +} + #[cfg(test)] fn run_after_safety_backup_test_seam() { if let Some(hook) = TEST_AFTER_SAFETY_BACKUP.with(|slot| slot.borrow_mut().take()) { @@ -94,25 +109,36 @@ const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"]; fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization { use rusqlite::hooks::{AuthAction, Authorization}; - let escapes_temp_db = match context.action { - AuthAction::Attach { .. } | AuthAction::Detach { .. } => true, - AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true, - // Genuine exports can contain expression indexes (for example, - // COALESCE in the request-log dedupe index), so ordinary built-ins - // must remain usable while the untrusted schema is assembled. - // No application functions are registered on this connection and - // extension loading is never enabled; deny the SQL entry point too. - AuthAction::Function { function_name } => { - function_name.eq_ignore_ascii_case("load_extension") - } - AuthAction::Unknown { .. } => true, - AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS - .iter() - .any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)), - _ => false, - }; + let escapes_scratch_boundary = context + .database_name + .is_some_and(|name| name.eq_ignore_ascii_case("temp")) + || match context.action { + AuthAction::Attach { .. } | AuthAction::Detach { .. } => true, + AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true, + AuthAction::CreateTempIndex { .. } + | AuthAction::CreateTempTable { .. } + | AuthAction::CreateTempTrigger { .. } + | AuthAction::CreateTempView { .. } + | AuthAction::DropTempIndex { .. } + | AuthAction::DropTempTable { .. } + | AuthAction::DropTempTrigger { .. } + | AuthAction::DropTempView { .. } => true, + // Genuine exports can contain expression indexes (for example, + // COALESCE in the request-log dedupe index), so ordinary built-ins + // must remain usable while the untrusted schema is assembled. + // No application functions are registered on this connection and + // extension loading is never enabled; deny the SQL entry point too. + AuthAction::Function { function_name } => { + function_name.eq_ignore_ascii_case("load_extension") + } + AuthAction::Unknown { .. } => true, + AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS + .iter() + .any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)), + _ => false, + }; - if escapes_temp_db { + if escapes_scratch_boundary { // SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。 log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action); Authorization::Deny @@ -185,7 +211,8 @@ enum RestoreRowValidator { Provider, Mcp, Profile, - DecimalColumns(&'static [usize]), + ProxyConfig, + NonNegativeDecimalColumns(&'static [usize]), } #[derive(Debug, Clone, Copy)] @@ -546,7 +573,7 @@ const RESTORE_TABLE_SPECS: &[RestoreTableSpec] = &[ name: "proxy_config", policy: RestorePolicy::PortableIncoming, columns: PROXY_CONFIG_RESTORE_COLUMNS, - validator: RestoreRowValidator::DecimalColumns(&[16]), + validator: RestoreRowValidator::ProxyConfig, parents: &[], }, RestoreTableSpec { @@ -560,14 +587,14 @@ const RESTORE_TABLE_SPECS: &[RestoreTableSpec] = &[ name: "proxy_request_logs", policy: RestorePolicy::PortableIncoming, columns: PROXY_LOG_RESTORE_COLUMNS, - validator: RestoreRowValidator::DecimalColumns(&[11, 12, 13, 14, 15, 24]), + validator: RestoreRowValidator::NonNegativeDecimalColumns(&[11, 12, 13, 14, 15, 24]), parents: &[], }, RestoreTableSpec { name: "model_pricing", policy: RestorePolicy::PortableIncoming, columns: MODEL_PRICING_RESTORE_COLUMNS, - validator: RestoreRowValidator::DecimalColumns(&[2, 3, 4, 5]), + validator: RestoreRowValidator::NonNegativeDecimalColumns(&[2, 3, 4, 5]), parents: &[], }, RestoreTableSpec { @@ -588,7 +615,7 @@ const RESTORE_TABLE_SPECS: &[RestoreTableSpec] = &[ name: "usage_daily_rollups", policy: RestorePolicy::PortableIncoming, columns: USAGE_ROLLUP_RESTORE_COLUMNS, - validator: RestoreRowValidator::DecimalColumns(&[13]), + validator: RestoreRowValidator::NonNegativeDecimalColumns(&[13]), parents: &[], }, RestoreTableSpec { @@ -648,7 +675,7 @@ fn open_nofollow(path: &Path) -> std::io::Result { OpenOptions::new() .read(true) - .custom_flags(libc::O_NOFOLLOW) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) .open(path) } @@ -665,7 +692,13 @@ fn open_nofollow(path: &Path) -> std::io::Result { #[cfg(all(not(unix), not(windows)))] fn open_nofollow(path: &Path) -> std::io::Result { - OpenOptions::new().read(true).open(path) + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "nofollow restore-source opens are unsupported on this platform: {}", + path.display() + ), + )) } #[cfg(unix)] @@ -675,11 +708,6 @@ fn same_file_identity(opened: &Metadata, current: &Metadata) -> bool { opened.dev() == current.dev() && opened.ino() == current.ino() } -#[cfg(not(unix))] -fn same_file_identity(opened: &Metadata, current: &Metadata) -> bool { - opened.len() == current.len() && opened.modified().ok() == current.modified().ok() -} - #[cfg(unix)] fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result { let opened = opened.metadata()?; @@ -722,9 +750,39 @@ fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result std::io::Result { - let opened = opened.metadata()?; - let current = current.metadata()?; - Ok(same_file_identity(&opened, ¤t)) + let _ = (opened, current); + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "stable restore-source identities are unsupported on this platform", + )) +} + +fn open_validated_restore_source( + path: &Path, + max_bytes: u64, + changed_message: &str, +) -> Result<(File, Metadata), AppError> { + // The path metadata is an early shape/size rejection only. The opened + // descriptor is the authority used for every byte read below. On Windows, + // std::fs::Metadata has no stable file ID, so it must never be compared by + // the old length+mtime surrogate. + let initial = validate_regular_file(path, max_bytes)?; + let file = open_nofollow(path).map_err(|error| AppError::io(path, error))?; + let opened = file.metadata().map_err(|error| AppError::io(path, error))?; + #[cfg(unix)] + let changed_before_open = !same_file_identity(&initial, &opened); + #[cfg(not(unix))] + let changed_before_open = { + let _shape_only = initial; + false + }; + if !opened.file_type().is_file() || opened.len() > max_bytes || changed_before_open { + return Err(AppError::InvalidInput(format!( + "{changed_message}: {}", + path.display() + ))); + } + Ok((file, opened)) } fn verify_open_file_still_current( @@ -778,18 +836,8 @@ fn validate_regular_file(path: &Path, max_bytes: u64) -> Result Result, AppError> { - let initial = validate_regular_file(path, max_bytes)?; - let mut file = open_nofollow(path).map_err(|error| AppError::io(path, error))?; - let opened = file.metadata().map_err(|error| AppError::io(path, error))?; - if !opened.file_type().is_file() - || opened.len() > max_bytes - || !same_file_identity(&initial, &opened) - { - return Err(AppError::InvalidInput(format!( - "restore source changed before open: {}", - path.display() - ))); - } + let (mut file, opened) = + open_validated_restore_source(path, max_bytes, "restore source changed before open")?; let mut bytes = Vec::new(); let mut limited: Take<&mut File> = Read::by_ref(&mut file).take(max_bytes + 1); limited @@ -813,20 +861,11 @@ fn read_restore_file(path: &Path, max_bytes: u64) -> Result, AppError> { /// the only path SQLite ever opens, so replacing any component of the original /// path cannot redirect the bytes consumed by the parser. fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result { - let initial = validate_regular_file(path, max_bytes)?; - let mut source = open_nofollow(path).map_err(|error| AppError::io(path, error))?; - let opened = source - .metadata() - .map_err(|error| AppError::io(path, error))?; - if !opened.file_type().is_file() - || opened.len() > max_bytes - || !same_file_identity(&initial, &opened) - { - return Err(AppError::InvalidInput(format!( - "binary restore source changed before open: {}", - path.display() - ))); - } + let (mut source, opened) = open_validated_restore_source( + path, + max_bytes, + "binary restore source changed before open", + )?; let mut owned = NamedTempFile::new().map_err(|error| AppError::IoContext { context: "create owned binary restore snapshot".to_string(), @@ -852,6 +891,46 @@ fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result, context: &str) -> Result<(), AppError> { + let mut transient_retries = 0_u32; + let mut total_steps = 0_u32; + loop { + if total_steps >= MAX_BACKUP_STEPS { + let progress = backup.progress(); + return Err(AppError::Database(format!( + "{context}: SQLite backup exceeded {MAX_BACKUP_STEPS} bounded steps \ + (remaining {}, total {})", + progress.remaining, progress.pagecount + ))); + } + total_steps += 1; + let result = backup + .step(BACKUP_PAGES_PER_STEP) + .map_err(|error| AppError::Database(format!("{context}: {error}")))?; + match result { + StepResult::Done => return Ok(()), + StepResult::More => transient_retries = 0, + StepResult::Busy | StepResult::Locked => { + if transient_retries >= max_backup_transient_retries() { + let progress = backup.progress(); + return Err(AppError::Database(format!( + "{context}: SQLite backup did not complete after {} transient retries \ + (remaining {}, total {})", + transient_retries, progress.remaining, progress.pagecount + ))); + } + transient_retries += 1; + std::thread::sleep(BACKUP_RETRY_DELAY); + } + _ => { + return Err(AppError::Database(format!( + "{context}: SQLite returned an unsupported backup step result" + ))); + } + } + } +} + impl UntrustedScratch { fn empty() -> Result { let file = NamedTempFile::new().map_err(|error| AppError::IoContext { @@ -946,9 +1025,7 @@ impl UntrustedScratch { { let backup = Backup::new(&source, &mut scratch.connection) .map_err(|error| AppError::Database(error.to_string()))?; - backup - .step(-1) - .map_err(|error| AppError::Database(error.to_string()))?; + run_backup_to_completion(&backup, "clone binary restore into private scratch")?; } drop(source); drop(owned_source); @@ -1121,6 +1198,21 @@ fn validate_json_text(table: &str, column: &str, value: &Value) -> Result<(), Ap }) } +fn validate_non_negative_decimal(table: &str, column: &str, value: &Value) -> Result<(), AppError> { + let value = text_value(table, column, value)?; + let parsed = value.parse::().map_err(|error| { + AppError::InvalidInput(format!( + "restore row has invalid decimal at {table}.{column}: {error}" + )) + })?; + if parsed < rust_decimal::Decimal::ZERO { + return Err(AppError::InvalidInput(format!( + "restore row has negative decimal at {table}.{column}: {value}" + ))); + } + Ok(()) +} + fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), AppError> { if values.len() != spec.columns.len() { return Err(AppError::Database(format!( @@ -1142,34 +1234,63 @@ fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), crate::database::dao::providers::validate_provider_storage_json( app_type, id, settings, meta, )?; - for index in [14_usize, 15, 16] { - if let Value::Text(value) = &values[index] { - value.parse::().map_err(|error| { - AppError::InvalidInput(format!( - "restore provider has invalid decimal in '{}': {error}", - spec.columns[index].name - )) - })?; + crate::database::validate_cost_multiplier(text_value( + spec.name, + "cost_multiplier", + &values[14], + )?)?; + for index in [15_usize, 16] { + if !matches!(values[index], Value::Null) { + validate_non_negative_decimal( + spec.name, + spec.columns[index].name, + &values[index], + )?; } } } RestoreRowValidator::Mcp => { validate_json_text(spec.name, "server_config", &values[2])?; - validate_json_text(spec.name, "tags", &values[6])?; + serde_json::from_str::>(text_value(spec.name, "tags", &values[6])?) + .map(|_| ()) + .map_err(|error| { + AppError::InvalidInput(format!( + "restore row has invalid MCP tags at {}.tags: {error}", + spec.name + )) + })?; } RestoreRowValidator::Profile => { - validate_json_text(spec.name, "payload", &values[2])?; + serde_json::from_str::(text_value( + spec.name, "payload", &values[2], + )?) + .map(|_| ()) + .map_err(|error| { + AppError::InvalidInput(format!( + "restore row has invalid ProfilePayload at {}.payload: {error}", + spec.name + )) + })?; } - RestoreRowValidator::DecimalColumns(indices) => { + RestoreRowValidator::ProxyConfig => { + crate::database::validate_cost_multiplier(text_value( + spec.name, + "default_cost_multiplier", + &values[16], + )?)?; + crate::database::validate_pricing_source(text_value( + spec.name, + "pricing_model_source", + &values[17], + )?)?; + } + RestoreRowValidator::NonNegativeDecimalColumns(indices) => { for index in indices { - if let Value::Text(value) = &values[*index] { - value.parse::().map_err(|error| { - AppError::InvalidInput(format!( - "restore row has invalid decimal at {}.{}: {error}", - spec.name, spec.columns[*index].name - )) - })?; - } + validate_non_negative_decimal( + spec.name, + spec.columns[*index].name, + &values[*index], + )?; } } } @@ -1525,7 +1646,7 @@ impl Database { Self::validate_cc_switch_sql_export(sql_content)?; let scratch = UntrustedScratch::from_sql(sql_content)?; let stage = Self::build_canonical_stage(&scratch)?; - let backup_path = self.commit_canonical_stage_with_safety_backup(stage, flavor)?; + let backup_path = self.publish_canonical_stage(stage, flavor)?; let backup_id = backup_path .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); @@ -1541,9 +1662,7 @@ impl Database { { let backup = Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?; - backup - .step(-1) - .map_err(|e| AppError::Database(e.to_string()))?; + run_backup_to_completion(&backup, "snapshot live database into memory")?; } Ok(snapshot) @@ -1583,26 +1702,19 @@ impl Database { Ok(stage) } - // Production restore entries use the safety-backup wrapper below. Keeping - // this direct boundary available under cfg(test) lets contract tests prove - // that publication itself accepts only CanonicalStage. - #[cfg_attr(not(test), allow(dead_code))] + /// Freeze live writes before the safety snapshot and keep the same + /// connection guard through publication. This is the sole publish + /// boundary: it consumes only a schema-factory-owned CanonicalStage, and no + /// helper accepts a raw Connection as a publishable source. fn publish_canonical_stage( &self, - stage: CanonicalStage, - flavor: RestoreFlavor, - ) -> Result<(), AppError> { - // CanonicalStage is the only accepted type. UntrustedScratch has no - // conversion or field access that can satisfy this boundary. - let mut main_conn = lock_conn!(self.conn); - Self::publish_canonical_stage_on_locked_live_connection(stage, flavor, &mut main_conn) - } - - fn publish_canonical_stage_on_locked_live_connection( mut stage: CanonicalStage, flavor: RestoreFlavor, - main_conn: &mut Connection, - ) -> Result<(), AppError> { + ) -> Result, AppError> { + let mut main_conn = lock_conn!(self.conn); + let safety_backup = Self::backup_database_file_on_locked_connection(&main_conn)?; + #[cfg(test)] + run_after_safety_backup_test_seam(); validate_canonical_stage(&stage)?; { let transaction = stage @@ -1614,7 +1726,7 @@ impl Database { || (flavor == RestoreFlavor::Sync && SYNC_LIVE_OVERLAY_TABLES.contains(&spec.name)); if preserve { - copy_fixed_table(main_conn, &transaction, spec, true)?; + copy_fixed_table(&main_conn, &transaction, spec, true)?; } } transaction @@ -1622,28 +1734,9 @@ impl Database { .map_err(|error| AppError::Database(error.to_string()))?; } validate_canonical_stage(&stage)?; - let backup = Backup::new(stage.connection(), main_conn) + let backup = Backup::new(stage.connection(), &mut main_conn) .map_err(|error| AppError::Database(error.to_string()))?; - backup - .step(-1) - .map(|_| ()) - .map_err(|error| AppError::Database(error.to_string())) - } - - /// Freeze live writes before the safety snapshot and keep the same - /// connection guard through publication. A writer can therefore be - /// ordered either before the safety backup or after publish, never inside - /// the loss window between them. - fn commit_canonical_stage_with_safety_backup( - &self, - stage: CanonicalStage, - flavor: RestoreFlavor, - ) -> Result, AppError> { - let mut main_conn = lock_conn!(self.conn); - let safety_backup = Self::backup_database_file_on_locked_connection(&main_conn)?; - #[cfg(test)] - run_after_safety_backup_test_seam(); - Self::publish_canonical_stage_on_locked_live_connection(stage, flavor, &mut main_conn)?; + run_backup_to_completion(&backup, "publish canonical restore stage")?; Ok(safety_backup) } @@ -1753,13 +1846,27 @@ impl Database { counter += 1; } - let mut dest_conn = - Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; - let backup = - Backup::new(conn, &mut dest_conn).map_err(|e| AppError::Database(e.to_string()))?; - backup - .step(-1) - .map_err(|e| AppError::Database(e.to_string()))?; + let result = (|| { + let mut dest_conn = + Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; + { + let backup = Backup::new(conn, &mut dest_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + run_backup_to_completion(&backup, "create database safety backup")?; + } + Ok(()) + })(); + if let Err(error) = result { + if let Err(remove_error) = fs::remove_file(&backup_path) { + if remove_error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "failed to remove incomplete safety backup '{}': {remove_error}", + backup_path.display() + ); + } + } + return Err(error); + } Self::cleanup_db_backups(&backup_dir)?; Ok(Some(backup_path)) @@ -2002,8 +2109,7 @@ impl Database { // The live connection guard is acquired before the safety snapshot and // remains held through publish, closing the write-loss window. - let safety_backup = - self.commit_canonical_stage_with_safety_backup(stage, RestoreFlavor::UserRestore)?; + let safety_backup = self.publish_canonical_stage(stage, RestoreFlavor::UserRestore)?; let safety_id = safety_backup .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); @@ -2107,8 +2213,8 @@ mod tests { assert_restore_policy_coverage, validate_canonical_behaviors, validate_regular_file, validate_stage_rows, Database, RestoreFlavor, RestorePolicy, RestoreRowValidator, StorageKind, UntrustedScratch, MAX_BINARY_RESTORE_BYTES, MAX_SCRATCH_BYTES, - MAX_SQL_IMPORT_BYTES, RESTORE_TABLE_SPECS, SCHEMA_VERSION, TEST_MAX_PAGE_COUNT, - TEST_MAX_VM_STEPS, + MAX_SQL_IMPORT_BYTES, RESTORE_TABLE_SPECS, SCHEMA_VERSION, + TEST_MAX_BACKUP_TRANSIENT_RETRIES, TEST_MAX_PAGE_COUNT, TEST_MAX_VM_STEPS, }; use crate::error::AppError; use crate::settings::{update_settings, AppSettings}; @@ -2168,6 +2274,20 @@ mod tests { } } + struct BackupRetryGuard(Option); + + impl BackupRetryGuard { + fn set(limit: u32) -> Self { + Self(TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(|current| current.replace(Some(limit)))) + } + } + + impl Drop for BackupRetryGuard { + fn drop(&mut self) { + TEST_MAX_BACKUP_TRANSIENT_RETRIES.with(|current| current.set(self.0)); + } + } + fn restore_policy_snapshot() -> serde_json::Value { let full_specs = RESTORE_TABLE_SPECS .iter() @@ -2185,8 +2305,9 @@ mod tests { RestoreRowValidator::Provider => serde_json::json!("provider"), RestoreRowValidator::Mcp => serde_json::json!("mcp"), RestoreRowValidator::Profile => serde_json::json!("profile"), - RestoreRowValidator::DecimalColumns(indices) => { - serde_json::json!({"decimalColumns": indices}) + RestoreRowValidator::ProxyConfig => serde_json::json!("proxy_config"), + RestoreRowValidator::NonNegativeDecimalColumns(indices) => { + serde_json::json!({"nonNegativeDecimalColumns": indices}) } }; serde_json::json!({ @@ -2389,7 +2510,7 @@ mod tests { let mut destination = Connection::open(&backup_path)?; { let backup = Backup::new(source, &mut destination)?; - backup.step(-1)?; + super::run_backup_to_completion(&backup, "prepare binary restore fixture")?; } drop(destination); target.restore_from_backup(filename) @@ -2530,6 +2651,8 @@ mod tests { #[derive(Debug, Clone, Copy)] enum InvalidRestoreCase { ProviderJson, + McpTagsShape, + ProfilePayloadShape, StorageClass, NegativeSortIndex, BooleanDomain, @@ -2538,6 +2661,11 @@ mod tests { NonNegativeI32Domain, Unsigned32Domain, InputTokenSemanticsDomain, + NegativeProviderMultiplier, + InvalidProviderPricingSource, + NegativeProxyMultiplier, + InvalidProxyPricingSource, + NegativeModelPrice, DuplicateProvider, DuplicateEndpoint, ForeignKeyOrphan, @@ -2545,8 +2673,10 @@ mod tests { } impl InvalidRestoreCase { - const ALL: [Self; 13] = [ + const ALL: [Self; 20] = [ Self::ProviderJson, + Self::McpTagsShape, + Self::ProfilePayloadShape, Self::StorageClass, Self::NegativeSortIndex, Self::BooleanDomain, @@ -2555,6 +2685,11 @@ mod tests { Self::NonNegativeI32Domain, Self::Unsigned32Domain, Self::InputTokenSemanticsDomain, + Self::NegativeProviderMultiplier, + Self::InvalidProviderPricingSource, + Self::NegativeProxyMultiplier, + Self::InvalidProxyPricingSource, + Self::NegativeModelPrice, Self::DuplicateProvider, Self::DuplicateEndpoint, Self::ForeignKeyOrphan, @@ -2564,6 +2699,8 @@ mod tests { fn label(self) -> &'static str { match self { Self::ProviderJson => "provider-json", + Self::McpTagsShape => "mcp-tags-shape", + Self::ProfilePayloadShape => "profile-payload-shape", Self::StorageClass => "storage-class", Self::NegativeSortIndex => "negative-sort-index", Self::BooleanDomain => "boolean-domain", @@ -2572,6 +2709,11 @@ mod tests { Self::NonNegativeI32Domain => "non-negative-i32-domain", Self::Unsigned32Domain => "unsigned-32-domain", Self::InputTokenSemanticsDomain => "input-token-semantics-domain", + Self::NegativeProviderMultiplier => "negative-provider-multiplier", + Self::InvalidProviderPricingSource => "invalid-provider-pricing-source", + Self::NegativeProxyMultiplier => "negative-proxy-multiplier", + Self::InvalidProxyPricingSource => "invalid-proxy-pricing-source", + Self::NegativeModelPrice => "negative-model-price", Self::DuplicateProvider => "duplicate-provider", Self::DuplicateEndpoint => "duplicate-endpoint", Self::ForeignKeyOrphan => "foreign-key-orphan", @@ -2597,6 +2739,24 @@ mod tests { [], )?; } + InvalidRestoreCase::McpTagsShape => { + source.execute( + "INSERT INTO mcp_servers (id, name, server_config, tags) + VALUES ('invalid-tags', 'Invalid Tags', '{}', '{}')", + [], + )?; + } + InvalidRestoreCase::ProfilePayloadShape => { + source.execute( + "INSERT INTO profiles (id, name, payload) + VALUES ( + 'invalid-profile', + 'Invalid Profile', + '{\"providers\":[1]}' + )", + [], + )?; + } InvalidRestoreCase::StorageClass => { source.execute( "UPDATE providers SET created_at = X'00' WHERE id = 'remote-provider'", @@ -2661,6 +2821,46 @@ mod tests { [], )?; } + InvalidRestoreCase::NegativeProviderMultiplier => { + source.execute( + "UPDATE providers SET cost_multiplier = '-1' + WHERE id = 'remote-provider'", + [], + )?; + } + InvalidRestoreCase::InvalidProviderPricingSource => { + source.execute( + "UPDATE providers SET meta = '{\"pricingModelSource\":\"invalid\"}' + WHERE id = 'remote-provider'", + [], + )?; + } + InvalidRestoreCase::NegativeProxyMultiplier => { + source.execute( + "UPDATE proxy_config SET default_cost_multiplier = '-1' + WHERE app_type = 'claude'", + [], + )?; + } + InvalidRestoreCase::InvalidProxyPricingSource => { + source.execute( + "UPDATE proxy_config SET pricing_model_source = 'invalid' + WHERE app_type = 'claude'", + [], + )?; + } + InvalidRestoreCase::NegativeModelPrice => { + 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 ( + 'negative-price', 'Negative Price', '-1', '0', '0', '0' + )", + [], + )?; + } InvalidRestoreCase::DuplicateProvider => { source.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) @@ -3076,6 +3276,52 @@ mod tests { })?; let _home_guard = TestHomeGuard::set(test_home.path()); + // Exercise the oldest supported layout, rather than merely relabeling + // a current schema. Missing columns must be supplied by the real + // v0..current migration chain before fixed-column restore begins. + for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] + .into_iter() + .enumerate() + { + let oldest = Connection::open_in_memory()?; + oldest.execute_batch( + "CREATE TABLE providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL DEFAULT '{}', + website_url TEXT, + PRIMARY KEY (id, app_type) + ); + INSERT INTO providers ( + id, app_type, name, settings_config, website_url + ) VALUES ( + 'actual-v0-provider', 'pi', 'Actual v0 Provider', '{}', NULL + ); + PRAGMA user_version = 0;", + )?; + let target = Database::memory()?; + run_restore_entry( + &target, + &oldest, + entry_point, + &format!("actual-v0-{entry_index}.db"), + )?; + let conn = crate::database::lock_conn!(target.conn); + let restored: i64 = conn.query_row( + "SELECT COUNT(*) FROM providers + WHERE id = 'actual-v0-provider' + AND app_type = 'pi' + AND cost_multiplier = '1.0'", + [], + |row| row.get(0), + )?; + assert_eq!(restored, 1, "actual oldest-layout migration sentinel"); + } + + // Every version still gets a public dispatch sentinel. The separate + // historical-layout case above prevents this matrix from passing only + // because current tables were stamped with an older user_version. for version in 0..=SCHEMA_VERSION { let source_db = Database::memory()?; { @@ -3178,6 +3424,39 @@ mod tests { Ok(()) } + #[test] + fn public_sql_restore_rejects_temp_schema_growth_atomically() -> Result<(), AppError> { + let malicious = format!( + "{}\n\ + CREATE TEMP TABLE scratch_escape(payload BLOB);\n\ + INSERT INTO scratch_escape(payload) VALUES (zeroblob(67108863));\n\ + INSERT INTO scratch_escape(payload) SELECT payload FROM scratch_escape;", + super::CC_SWITCH_SQL_EXPORT_HEADER + ); + + for sync in [false, true] { + let database = Database::memory()?; + seed_live_restore_state(&database)?; + let before = logical_snapshot(&database)?; + let result = if sync { + database.import_sql_string_for_sync(&malicious) + } else { + database.import_sql_string(&malicious) + }; + assert!( + result.is_err(), + "TEMP schema DDL must be rejected by the public {} SQL entry", + if sync { "sync" } else { "user" } + ); + assert_eq!( + logical_snapshot(&database)?, + before, + "rejected TEMP growth changed live state" + ); + } + Ok(()) + } + #[test] #[serial] fn public_file_restore_entries_reject_symlink_directory_and_fifo() -> Result<(), AppError> { @@ -3236,6 +3515,53 @@ mod tests { Ok(()) } + #[cfg(any(unix, windows))] + #[test] + fn restore_file_identity_is_not_a_size_and_timestamp_surrogate() -> Result<(), AppError> { + use std::fs::FileTimes; + use std::time::{Duration, SystemTime}; + + let directory = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create restore identity test directory".to_string(), + source: error, + })?; + let first_path = directory.path().join("first.db"); + let second_path = directory.path().join("second.db"); + fs::write(&first_path, b"same-size").map_err(|error| AppError::io(&first_path, error))?; + fs::write(&second_path, b"different").map_err(|error| AppError::io(&second_path, error))?; + + let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let times = FileTimes::new().set_modified(timestamp); + let first = + super::open_nofollow(&first_path).map_err(|error| AppError::io(&first_path, error))?; + let second = super::open_nofollow(&second_path) + .map_err(|error| AppError::io(&second_path, error))?; + first + .set_times(times) + .map_err(|error| AppError::io(&first_path, error))?; + second + .set_times(times) + .map_err(|error| AppError::io(&second_path, error))?; + + let first_metadata = first + .metadata() + .map_err(|error| AppError::io(&first_path, error))?; + let second_metadata = second + .metadata() + .map_err(|error| AppError::io(&second_path, error))?; + assert_eq!(first_metadata.len(), second_metadata.len()); + assert_eq!( + first_metadata.modified().ok(), + second_metadata.modified().ok() + ); + assert!( + !super::same_open_file_identity(&first, &second) + .map_err(|error| AppError::io(&first_path, error))?, + "stable file IDs must distinguish equal-size/equal-mtime files" + ); + Ok(()) + } + #[test] #[serial] fn restore_safety_backup_and_publish_hold_one_live_write_boundary() -> Result<(), AppError> { @@ -3280,7 +3606,7 @@ mod tests { let mut destination = Connection::open(&source_path)?; { let backup = Backup::new(&source, &mut destination)?; - backup.step(-1)?; + super::run_backup_to_completion(&backup, "prepare write-boundary fixture")?; } drop(destination); @@ -3372,6 +3698,138 @@ mod tests { Ok(()) } + #[test] + #[serial] + fn incomplete_safety_backup_is_rejected_and_removed() -> Result<(), AppError> { + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create safety-backup contention home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let database = Database::init()?; + { + let conn = crate::database::lock_conn!(database.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('live-before-busy-backup', 'pi', 'Live', '{}', '{}')", + [], + )?; + } + + let live_path = crate::config::get_app_config_dir().join("cc-switch.db"); + let external = Connection::open(&live_path)?; + external.execute_batch( + "BEGIN EXCLUSIVE; + INSERT INTO settings (key, value) VALUES ('uncommitted-lock', 'held');", + )?; + let _retry_guard = BackupRetryGuard::set(0); + let result = database.backup_database_file(); + external.execute_batch("ROLLBACK;")?; + + assert!( + result.is_err(), + "a transient SQLite backup result must not be reported as success" + ); + let backup_dir = crate::config::get_app_config_dir().join("backups"); + let leftovers = fs::read_dir(&backup_dir) + .map(|entries| entries.filter_map(Result::ok).count()) + .unwrap_or(0); + assert_eq!(leftovers, 0, "incomplete safety artifacts must be removed"); + Ok(()) + } + + #[test] + #[serial] + fn incomplete_publish_is_rejected_and_live_database_is_unchanged() -> Result<(), AppError> { + use std::sync::mpsc; + + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create publish contention home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let database = Database::init()?; + { + let conn = crate::database::lock_conn!(database.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('live-before-busy-publish', 'pi', 'Live', '{}', '{}')", + [], + )?; + } + let before = logical_snapshot(&database)?; + let source = canonical_restore_source()?; + let sql = Database::dump_sql(&source, &[])?; + + let live_path = crate::config::get_app_config_dir().join("cc-switch.db"); + let (start_tx, start_rx) = mpsc::channel(); + let (locked_tx, locked_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let locker = std::thread::spawn(move || -> Result<(), String> { + start_rx.recv().map_err(|error| error.to_string())?; + let external = Connection::open(live_path).map_err(|error| error.to_string())?; + external + .execute_batch("BEGIN EXCLUSIVE;") + .map_err(|error| error.to_string())?; + locked_tx.send(()).map_err(|error| error.to_string())?; + release_rx.recv().map_err(|error| error.to_string())?; + external + .execute_batch("ROLLBACK;") + .map_err(|error| error.to_string()) + }); + super::TEST_AFTER_SAFETY_BACKUP.with(|slot| { + *slot.borrow_mut() = Some(Box::new(move || { + start_tx.send(()).expect("start external publish lock"); + locked_rx.recv().expect("external publish lock acquired"); + })); + }); + + let _retry_guard = BackupRetryGuard::set(0); + let result = database.import_sql_string(&sql); + release_tx + .send(()) + .expect("release external publish lock after restore result"); + locker + .join() + .expect("publish-lock thread did not panic") + .map_err(AppError::Message)?; + + assert!( + result.is_err(), + "a non-Done publication must not be reported as success" + ); + assert_eq!( + logical_snapshot(&database)?, + before, + "failed publication changed the live database" + ); + let backup_dir = crate::config::get_app_config_dir().join("backups"); + let safety_paths = fs::read_dir(&backup_dir) + .map_err(|error| AppError::io(&backup_dir, error))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "db")) + .collect::>(); + assert!( + !safety_paths.is_empty(), + "completed safety backup remains available" + ); + let complete_safety_exists = safety_paths.iter().any(|path| { + Connection::open(path) + .and_then(|safety| { + safety.query_row( + "SELECT COUNT(*) FROM providers + WHERE id = 'live-before-busy-publish' AND app_type = 'pi'", + [], + |row| row.get::<_, i64>(0), + ) + }) + .is_ok_and(|count| count == 1) + }); + assert!(complete_safety_exists, "safety backup must be complete"); + Ok(()) + } + #[test] #[serial] fn restore_file_size_limits_accept_n_and_publicly_reject_n_plus_one() -> Result<(), AppError> { @@ -3466,7 +3924,7 @@ mod tests { let mut destination = Connection::open(&binary_path)?; { let backup = Backup::new(&source, &mut destination)?; - backup.step(-1)?; + super::run_backup_to_completion(&backup, "prepare page-budget fixture")?; } drop(destination); { @@ -3757,12 +4215,11 @@ mod tests { #[test] #[serial] fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> { - let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); - let test_home = - std::env::temp_dir().join("cc-switch-periodic-maintenance-backup-disabled-test"); - let _ = std::fs::remove_dir_all(&test_home); - std::fs::create_dir_all(&test_home).expect("create test home"); - std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create periodic-maintenance test home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); let settings = AppSettings { backup_interval_hours: Some(0), @@ -3823,11 +4280,6 @@ mod tests { ); assert_eq!(rollups, 1, "old request logs should be rolled up"); - match old_test_home { - Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), - None => std::env::remove_var("CC_SWITCH_TEST_HOME"), - } - Ok(()) } } diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 8953c2fc7..8537da90c 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -93,7 +93,14 @@ pub(crate) fn validate_provider_storage_json( settings_config: &str, meta: &str, ) -> Result<(), AppError> { - decode_provider_json(app_type, provider_id, settings_config, meta).map(|_| ()) + let (_, meta) = decode_provider_json(app_type, provider_id, settings_config, meta)?; + if let Some(multiplier) = meta.cost_multiplier.as_deref() { + super::proxy::validate_cost_multiplier(multiplier)?; + } + if let Some(source) = meta.pricing_model_source.as_deref() { + super::proxy::validate_pricing_source(source)?; + } + Ok(()) } pub(super) const PROVIDER_SELECT: &str = diff --git a/tests/fixtures/pi/restore-policy-v1.json b/tests/fixtures/pi/restore-policy-v1.json index 21c74a502..b411df23f 100644 --- a/tests/fixtures/pi/restore-policy-v1.json +++ b/tests/fixtures/pi/restore-policy-v1.json @@ -7,7 +7,7 @@ "binaryRestoreBytes": 2147483648, "scratchBytes": 2147483648 }, - "specSha256": "c0bf1a34172de0b4d63ca597973c6126d8d5cbba42e73c0b7a7ff043f2a58cdb", + "specSha256": "e2b44f6fbbb1ca7287487e68aeb5684853c024978f418be688fcd15d665fd3aa", "tables": [ {"name": "providers", "policy": "portable_incoming"}, {"name": "provider_endpoints", "policy": "portable_incoming"},