From 2841811700bf3904c8e701118f75fd907c875de9 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sat, 1 Aug 2026 11:44:50 +0000 Subject: [PATCH] refactor(database): complete prerequisite B --- src-tauri/src/database/backup.rs | 781 +++++++++++++++--- .../database/backup_restore_certification.rs | 609 ++++++++++++++ src-tauri/src/database/mod.rs | 2 + src-tauri/src/database/schema.rs | 3 +- tests/fixtures/pi/restore-policy-v1.json | 2 +- 5 files changed, 1258 insertions(+), 139 deletions(-) create mode 100644 src-tauri/src/database/backup_restore_certification.rs diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 9abd30347..e59f79d66 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -13,7 +13,7 @@ use rusqlite::limits::Limit; use rusqlite::types::{Value, ValueRef}; use rusqlite::{Connection, OpenFlags}; use std::fs::{self, File, Metadata, OpenOptions}; -use std::io::{Read, Take}; +use std::io::{Read, Take, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -35,6 +35,9 @@ thread_local! { const { std::cell::Cell::new(None) }; static TEST_MAX_PAGE_COUNT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + static TEST_AFTER_SAFETY_BACKUP: + std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; } fn max_vm_steps() -> u64 { @@ -53,6 +56,13 @@ fn max_page_count() -> u64 { MAX_PAGE_COUNT } +#[cfg(test)] +fn run_after_safety_backup_test_seam() { + if let Some(hook) = TEST_AFTER_SAFETY_BACKUP.with(|slot| slot.borrow_mut().take()) { + hook(); + } +} + /// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory` /// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。 const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"]; @@ -141,11 +151,30 @@ enum StorageKind { Real, } +/// Semantic range of an INTEGER column at the production hydration boundary. +/// +/// SQLite stores every INTEGER as an `i64`, while several public projections +/// narrow those values to `bool`, `u8`, `u16`, `u32`, `u64`, or `usize`. +/// Restore must reject values that those projections would wrap or reinterpret +/// instead of publishing a database that fails only when the row is later read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IntegerDomain { + Unrestricted, + Boolean, + NonNegative, + Unsigned8, + Unsigned16, + NonNegativeI32, + Unsigned32, + InputTokenSemantics, +} + #[derive(Debug, Clone, Copy)] struct RestoreColumnSpec { name: &'static str, storage: StorageKind, nullable: bool, + integer_domain: IntegerDomain, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -174,6 +203,7 @@ macro_rules! text_col { name: $name, storage: StorageKind::Text, nullable: false, + integer_domain: IntegerDomain::Unrestricted, } }; } @@ -184,26 +214,29 @@ macro_rules! nullable_text_col { name: $name, storage: StorageKind::Text, nullable: true, + integer_domain: IntegerDomain::Unrestricted, } }; } macro_rules! integer_col { - ($name:literal) => { + ($name:literal, $domain:ident) => { RestoreColumnSpec { name: $name, storage: StorageKind::Integer, nullable: false, + integer_domain: IntegerDomain::$domain, } }; } macro_rules! nullable_integer_col { - ($name:literal) => { + ($name:literal, $domain:ident) => { RestoreColumnSpec { name: $name, storage: StorageKind::Integer, nullable: true, + integer_domain: IntegerDomain::$domain, } }; } @@ -214,6 +247,7 @@ macro_rules! real_col { name: $name, storage: StorageKind::Real, nullable: false, + integer_domain: IntegerDomain::Unrestricted, } }; } @@ -225,14 +259,14 @@ const PROVIDERS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("settings_config"), nullable_text_col!("website_url"), nullable_text_col!("category"), - nullable_integer_col!("created_at"), - nullable_integer_col!("sort_index"), + nullable_integer_col!("created_at", Unrestricted), + nullable_integer_col!("sort_index", NonNegative), nullable_text_col!("notes"), nullable_text_col!("icon"), nullable_text_col!("icon_color"), text_col!("meta"), - integer_col!("is_current"), - integer_col!("in_failover_queue"), + integer_col!("is_current", Boolean), + integer_col!("in_failover_queue", Boolean), text_col!("cost_multiplier"), nullable_text_col!("limit_daily_usd"), nullable_text_col!("limit_monthly_usd"), @@ -240,12 +274,15 @@ const PROVIDERS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ ]; const PROVIDER_ENDPOINTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ - integer_col!("id"), + // Explicit INTEGER PRIMARY KEY values are portable data. SQLite permits + // negative explicit IDs even though AUTOINCREMENT only generates positive + // values, so restore must preserve the full i64 domain. + integer_col!("id", Unrestricted), text_col!("provider_id"), text_col!("app_type"), text_col!("url"), - nullable_integer_col!("added_at"), - nullable_integer_col!("last_used"), + nullable_integer_col!("added_at", Unrestricted), + nullable_integer_col!("last_used", Unrestricted), ]; const MCP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ @@ -256,12 +293,12 @@ const MCP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ nullable_text_col!("homepage"), nullable_text_col!("docs"), text_col!("tags"), - integer_col!("enabled_claude"), - integer_col!("enabled_codex"), - integer_col!("enabled_gemini"), - integer_col!("enabled_grokbuild"), - integer_col!("enabled_opencode"), - integer_col!("enabled_hermes"), + integer_col!("enabled_claude", Boolean), + integer_col!("enabled_codex", Boolean), + integer_col!("enabled_gemini", Boolean), + integer_col!("enabled_grokbuild", Boolean), + integer_col!("enabled_opencode", Boolean), + integer_col!("enabled_hermes", Boolean), ]; const PROMPTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ @@ -270,9 +307,9 @@ const PROMPTS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("name"), text_col!("content"), nullable_text_col!("description"), - integer_col!("enabled"), - nullable_integer_col!("created_at"), - nullable_integer_col!("updated_at"), + integer_col!("enabled", Boolean), + nullable_integer_col!("created_at", Unrestricted), + nullable_integer_col!("updated_at", Unrestricted), ]; const SKILLS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ @@ -284,23 +321,23 @@ const SKILLS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ nullable_text_col!("repo_name"), nullable_text_col!("repo_branch"), nullable_text_col!("readme_url"), - integer_col!("enabled_claude"), - integer_col!("enabled_codex"), - integer_col!("enabled_gemini"), - integer_col!("enabled_grokbuild"), - integer_col!("enabled_opencode"), - integer_col!("enabled_hermes"), - integer_col!("enabled_pi"), - integer_col!("installed_at"), + integer_col!("enabled_claude", Boolean), + integer_col!("enabled_codex", Boolean), + integer_col!("enabled_gemini", Boolean), + integer_col!("enabled_grokbuild", Boolean), + integer_col!("enabled_opencode", Boolean), + integer_col!("enabled_hermes", Boolean), + integer_col!("enabled_pi", Boolean), + integer_col!("installed_at", Unrestricted), nullable_text_col!("content_hash"), - integer_col!("updated_at"), + integer_col!("updated_at", Unrestricted), ]; const SKILL_REPOS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("owner"), text_col!("name"), text_col!("branch"), - integer_col!("enabled"), + integer_col!("enabled", Boolean), ]; const SETTINGS_RESTORE_COLUMNS: &[RestoreColumnSpec] = @@ -308,24 +345,24 @@ const SETTINGS_RESTORE_COLUMNS: &[RestoreColumnSpec] = const PROXY_CONFIG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("app_type"), - integer_col!("proxy_enabled"), + integer_col!("proxy_enabled", Boolean), text_col!("listen_address"), - integer_col!("listen_port"), - integer_col!("enable_logging"), - integer_col!("enabled"), - integer_col!("auto_failover_enabled"), - integer_col!("max_retries"), - integer_col!("streaming_first_byte_timeout"), - integer_col!("streaming_idle_timeout"), - integer_col!("non_streaming_timeout"), - integer_col!("circuit_failure_threshold"), - integer_col!("circuit_success_threshold"), - integer_col!("circuit_timeout_seconds"), + integer_col!("listen_port", Unsigned16), + integer_col!("enable_logging", Boolean), + integer_col!("enabled", Boolean), + integer_col!("auto_failover_enabled", Boolean), + integer_col!("max_retries", Unsigned8), + integer_col!("streaming_first_byte_timeout", NonNegativeI32), + integer_col!("streaming_idle_timeout", NonNegativeI32), + integer_col!("non_streaming_timeout", NonNegativeI32), + integer_col!("circuit_failure_threshold", NonNegativeI32), + integer_col!("circuit_success_threshold", NonNegativeI32), + integer_col!("circuit_timeout_seconds", NonNegativeI32), real_col!("circuit_error_rate_threshold"), - integer_col!("circuit_min_requests"), + integer_col!("circuit_min_requests", NonNegativeI32), text_col!("default_cost_multiplier"), text_col!("pricing_model_source"), - integer_col!("live_takeover_active"), + integer_col!("live_takeover_active", Boolean), text_col!("created_at"), text_col!("updated_at"), ]; @@ -333,8 +370,8 @@ const PROXY_CONFIG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ const PROVIDER_HEALTH_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("provider_id"), text_col!("app_type"), - integer_col!("is_healthy"), - integer_col!("consecutive_failures"), + integer_col!("is_healthy", Boolean), + integer_col!("consecutive_failures", Unsigned32), nullable_text_col!("last_success_at"), nullable_text_col!("last_failure_at"), nullable_text_col!("last_error"), @@ -348,26 +385,26 @@ const PROXY_LOG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("model"), nullable_text_col!("request_model"), nullable_text_col!("pricing_model"), - integer_col!("input_tokens"), - integer_col!("output_tokens"), - integer_col!("cache_read_tokens"), - integer_col!("cache_creation_tokens"), - integer_col!("input_token_semantics"), + integer_col!("input_tokens", Unsigned32), + integer_col!("output_tokens", Unsigned32), + integer_col!("cache_read_tokens", Unsigned32), + integer_col!("cache_creation_tokens", Unsigned32), + integer_col!("input_token_semantics", InputTokenSemantics), text_col!("input_cost_usd"), text_col!("output_cost_usd"), text_col!("cache_read_cost_usd"), text_col!("cache_creation_cost_usd"), text_col!("total_cost_usd"), - integer_col!("latency_ms"), - nullable_integer_col!("first_token_ms"), - nullable_integer_col!("duration_ms"), - integer_col!("status_code"), + integer_col!("latency_ms", NonNegative), + nullable_integer_col!("first_token_ms", NonNegative), + nullable_integer_col!("duration_ms", NonNegative), + integer_col!("status_code", Unsigned16), nullable_text_col!("error_message"), nullable_text_col!("session_id"), nullable_text_col!("provider_type"), - integer_col!("is_streaming"), + integer_col!("is_streaming", Boolean), text_col!("cost_multiplier"), - integer_col!("created_at"), + integer_col!("created_at", Unrestricted), text_col!("data_source"), ]; @@ -381,18 +418,18 @@ const MODEL_PRICING_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ ]; const STREAM_LOG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ - integer_col!("id"), + integer_col!("id", Unrestricted), text_col!("provider_id"), text_col!("provider_name"), text_col!("app_type"), text_col!("status"), - integer_col!("success"), + integer_col!("success", Boolean), text_col!("message"), - nullable_integer_col!("response_time_ms"), - nullable_integer_col!("http_status"), + nullable_integer_col!("response_time_ms", NonNegative), + nullable_integer_col!("http_status", Unsigned16), nullable_text_col!("model_used"), - nullable_integer_col!("retry_count"), - integer_col!("tested_at"), + nullable_integer_col!("retry_count", Unsigned32), + integer_col!("tested_at", Unrestricted), ]; const PROXY_LIVE_BACKUP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ @@ -408,38 +445,38 @@ const USAGE_ROLLUP_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("model"), text_col!("request_model"), text_col!("pricing_model"), - integer_col!("request_count"), - integer_col!("success_count"), - integer_col!("input_tokens"), - integer_col!("output_tokens"), - integer_col!("cache_read_tokens"), - integer_col!("cache_creation_tokens"), - integer_col!("input_token_semantics"), + integer_col!("request_count", NonNegative), + integer_col!("success_count", NonNegative), + integer_col!("input_tokens", NonNegative), + integer_col!("output_tokens", NonNegative), + integer_col!("cache_read_tokens", NonNegative), + integer_col!("cache_creation_tokens", NonNegative), + integer_col!("input_token_semantics", InputTokenSemantics), text_col!("total_cost_usd"), - integer_col!("avg_latency_ms"), + integer_col!("avg_latency_ms", NonNegative), ]; const SESSION_SYNC_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("file_path"), - integer_col!("last_modified"), - integer_col!("last_line_offset"), - integer_col!("last_synced_at"), + integer_col!("last_modified", Unrestricted), + integer_col!("last_line_offset", NonNegative), + integer_col!("last_synced_at", Unrestricted), ]; const PROFILE_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("id"), text_col!("name"), text_col!("payload"), - nullable_integer_col!("sort_order"), - nullable_integer_col!("created_at"), - nullable_integer_col!("updated_at"), + nullable_integer_col!("sort_order", Unrestricted), + nullable_integer_col!("created_at", Unrestricted), + nullable_integer_col!("updated_at", Unrestricted), ]; const PI_PROJECTION_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("provider_id"), text_col!("provider_key"), - integer_col!("created_at"), - integer_col!("updated_at"), + integer_col!("created_at", Unrestricted), + integer_col!("updated_at", Unrestricted), ]; const SKILL_DEPLOYMENT_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ @@ -450,8 +487,8 @@ const SKILL_DEPLOYMENT_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ text_col!("method"), text_col!("source_identity"), nullable_text_col!("deployed_digest"), - integer_col!("created_at"), - integer_col!("updated_at"), + integer_col!("created_at", Unrestricted), + integer_col!("updated_at", Unrestricted), ]; /// Parent-before-child order is also the canonical copy order. @@ -615,7 +652,18 @@ fn open_nofollow(path: &Path) -> std::io::Result { .open(path) } -#[cfg(not(unix))] +#[cfg(windows)] +fn open_nofollow(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + + OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(all(not(unix), not(windows)))] fn open_nofollow(path: &Path) -> std::io::Result { OpenOptions::new().read(true).open(path) } @@ -632,6 +680,86 @@ 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()?; + let current = current.metadata()?; + Ok(same_file_identity(&opened, ¤t)) +} + +#[cfg(windows)] +fn windows_file_identity(file: &File) -> std::io::Result<(u64, [u8; 16])> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO, + }; + + let mut information = FILE_ID_INFO::default(); + // SAFETY: `file` owns a live handle for this call, and `information` is a + // valid writable FILE_ID_INFO buffer of the size passed to Windows. + let succeeded = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + std::ptr::addr_of_mut!(information).cast(), + std::mem::size_of::() as u32, + ) + } != 0; + if succeeded { + Ok(( + information.VolumeSerialNumber, + information.FileId.Identifier, + )) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result { + Ok(windows_file_identity(opened)? == windows_file_identity(current)?) +} + +#[cfg(all(not(unix), not(windows)))] +fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result { + let opened = opened.metadata()?; + let current = current.metadata()?; + Ok(same_file_identity(&opened, ¤t)) +} + +fn verify_open_file_still_current( + path: &Path, + opened_file: &File, + opened: &Metadata, + consumed_len: u64, +) -> Result<(), AppError> { + let completed = opened_file + .metadata() + .map_err(|error| AppError::io(path, error))?; + let current_path = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; + let current_file = open_nofollow(path).map_err(|error| AppError::io(path, error))?; + let current_opened = current_file + .metadata() + .map_err(|error| AppError::io(path, error))?; + let same_identity = same_open_file_identity(opened_file, ¤t_file) + .map_err(|error| AppError::io(path, error))?; + if !current_path.file_type().is_file() + || !current_opened.file_type().is_file() + || !same_identity + || opened.len() != consumed_len + || completed.len() != consumed_len + || current_path.len() != consumed_len + || current_opened.len() != consumed_len + || opened.modified().ok() != completed.modified().ok() + { + return Err(AppError::InvalidInput(format!( + "restore source changed while it was read: {}", + path.display() + ))); + } + Ok(()) +} + fn validate_regular_file(path: &Path, max_bytes: u64) -> Result { let metadata = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; if !metadata.file_type().is_file() { @@ -663,7 +791,7 @@ fn read_restore_file(path: &Path, max_bytes: u64) -> Result, AppError> { ))); } let mut bytes = Vec::new(); - let mut limited: Take<&mut File> = file.by_ref().take(max_bytes + 1); + let mut limited: Take<&mut File> = Read::by_ref(&mut file).take(max_bytes + 1); limited .read_to_end(&mut bytes) .map_err(|error| AppError::io(path, error))?; @@ -673,21 +801,55 @@ fn read_restore_file(path: &Path, max_bytes: u64) -> Result, AppError> { path.display() ))); } - let completed = file.metadata().map_err(|error| AppError::io(path, error))?; - let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; - if !current.file_type().is_file() - || !same_file_identity(&opened, ¤t) - || opened.len() != bytes.len() as u64 - || completed.len() != bytes.len() as u64 - || current.len() != bytes.len() as u64 - || opened.modified().ok() != completed.modified().ok() + verify_open_file_still_current(path, &file, &opened, bytes.len() as u64)?; + Ok(bytes) +} + +/// Snapshot an already-validated restore source through the `O_NOFOLLOW` +/// descriptor into a process-owned temporary file. +/// +/// SQLite's path-based open API would otherwise resolve the user-controlled +/// backup path a second time after the identity check. The owned snapshot is +/// 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!( - "restore source changed during read: {}", + "binary restore source changed before open: {}", path.display() ))); } - Ok(bytes) + + let mut owned = NamedTempFile::new().map_err(|error| AppError::IoContext { + context: "create owned binary restore snapshot".to_string(), + source: error, + })?; + let copied = std::io::copy( + &mut Read::by_ref(&mut source).take(max_bytes + 1), + owned.as_file_mut(), + ) + .map_err(|error| AppError::io(path, error))?; + if copied > max_bytes { + return Err(AppError::InvalidInput(format!( + "restore source exceeds {max_bytes} bytes: {}", + path.display() + ))); + } + owned + .as_file_mut() + .flush() + .map_err(|error| AppError::io(owned.path(), error))?; + + verify_open_file_still_current(path, &source, &opened, copied)?; + Ok(owned) } impl UntrustedScratch { @@ -772,19 +934,9 @@ impl UntrustedScratch { } fn from_binary(path: &Path) -> Result { - let initial = validate_regular_file(path, MAX_BINARY_RESTORE_BYTES)?; - let guard = open_nofollow(path).map_err(|error| AppError::io(path, error))?; - let opened = guard - .metadata() - .map_err(|error| AppError::io(path, error))?; - if !same_file_identity(&initial, &opened) { - return Err(AppError::InvalidInput(format!( - "binary restore source changed before open: {}", - path.display() - ))); - } + let owned_source = snapshot_binary_restore_file(path, MAX_BINARY_RESTORE_BYTES)?; let source = Connection::open_with_flags( - path, + owned_source.path(), OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NOFOLLOW | OpenFlags::SQLITE_OPEN_PRIVATE_CACHE, @@ -798,19 +950,8 @@ impl UntrustedScratch { .step(-1) .map_err(|error| AppError::Database(error.to_string()))?; } - let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; - if !current.file_type().is_file() - || !same_file_identity(&opened, ¤t) - || opened.len() != current.len() - || opened.modified().ok() != current.modified().ok() - { - return Err(AppError::InvalidInput(format!( - "binary restore source changed during snapshot: {}", - path.display() - ))); - } drop(source); - drop(guard); + drop(owned_source); scratch.finish_input() } @@ -932,6 +1073,34 @@ fn validate_storage( } } +fn validate_integer_domain( + table: &str, + column: &RestoreColumnSpec, + value: &Value, +) -> Result<(), AppError> { + let Value::Integer(value) = value else { + return Ok(()); + }; + let valid = match column.integer_domain { + IntegerDomain::Unrestricted => true, + IntegerDomain::Boolean => matches!(*value, 0 | 1), + IntegerDomain::NonNegative => *value >= 0, + IntegerDomain::Unsigned8 => (0..=u8::MAX as i64).contains(value), + IntegerDomain::Unsigned16 => (0..=u16::MAX as i64).contains(value), + IntegerDomain::NonNegativeI32 => (0..=i32::MAX as i64).contains(value), + IntegerDomain::Unsigned32 => (0..=u32::MAX as i64).contains(value), + IntegerDomain::InputTokenSemantics => (0..=2).contains(value), + }; + if valid { + Ok(()) + } else { + Err(AppError::InvalidInput(format!( + "restore row has out-of-domain integer {value} at {table}.{} ({:?})", + column.name, column.integer_domain + ))) + } +} + fn text_value<'a>(table: &str, column: &str, value: &'a Value) -> Result<&'a str, AppError> { match value { Value::Text(value) => Ok(value), @@ -961,6 +1130,7 @@ fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), } for (column, value) in spec.columns.iter().zip(values) { validate_storage(spec.name, column, value)?; + validate_integer_domain(spec.name, column, value)?; } match spec.validator { RestoreRowValidator::OpaqueStorage => {} @@ -1068,6 +1238,16 @@ fn assert_restore_policy_topology() -> Result<(), AppError> { spec.name ))); } + for column in spec.columns { + if column.storage != StorageKind::Integer + && column.integer_domain != IntegerDomain::Unrestricted + { + return Err(AppError::Database(format!( + "non-integer restore column '{}.{}' declares an integer domain", + spec.name, column.name + ))); + } + } for parent in spec.parents { if !seen.contains(parent) { return Err(AppError::Database(format!( @@ -1345,8 +1525,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.backup_database_file()?; - self.publish_canonical_stage(stage, flavor)?; + let backup_path = self.commit_canonical_stage_with_safety_backup(stage, flavor)?; let backup_id = backup_path .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); @@ -1404,15 +1583,27 @@ 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))] fn publish_canonical_stage( &self, - mut stage: CanonicalStage, + stage: CanonicalStage, flavor: RestoreFlavor, ) -> Result<(), AppError> { // CanonicalStage is the only accepted type. UntrustedScratch has no // conversion or field access that can satisfy this boundary. - validate_canonical_stage(&stage)?; 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> { + validate_canonical_stage(&stage)?; { let transaction = stage .connection_mut() @@ -1423,7 +1614,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 @@ -1431,7 +1622,7 @@ impl Database { .map_err(|error| AppError::Database(error.to_string()))?; } validate_canonical_stage(&stage)?; - let backup = Backup::new(stage.connection(), &mut main_conn) + let backup = Backup::new(stage.connection(), main_conn) .map_err(|error| AppError::Database(error.to_string()))?; backup .step(-1) @@ -1439,6 +1630,23 @@ impl Database { .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)?; + Ok(safety_backup) + } + fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> { let trimmed = sql.trim_start(); if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) { @@ -1516,6 +1724,13 @@ impl Database { /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) pub(crate) fn backup_database_file(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + Self::backup_database_file_on_locked_connection(&conn) + } + + fn backup_database_file_on_locked_connection( + conn: &Connection, + ) -> Result, AppError> { let db_path = get_app_config_dir().join("cc-switch.db"); if !db_path.exists() { return Ok(None); @@ -1538,16 +1753,13 @@ impl Database { counter += 1; } - { - let conn = lock_conn!(self.conn); - 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 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()))?; Self::cleanup_db_backups(&backup_dir)?; Ok(Some(backup_path)) @@ -1788,12 +2000,13 @@ impl Database { let scratch = UntrustedScratch::from_binary(&backup_path)?; let stage = Self::build_canonical_stage(&scratch)?; - // Create the safety backup only after the source has passed staging. - let safety_backup = self.backup_database_file()?; + // 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_id = safety_backup .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); - self.publish_canonical_stage(stage, RestoreFlavor::UserRestore)?; log::info!("Database restored from backup: {filename}, safety backup: {safety_id}"); Ok(safety_id) @@ -1985,7 +2198,24 @@ mod tests { StorageKind::Integer => "integer", StorageKind::Real => "real", }; - serde_json::json!([column.name, storage, column.nullable]) + let integer_domain = match column.integer_domain { + super::IntegerDomain::Unrestricted => "unrestricted", + super::IntegerDomain::Boolean => "boolean", + super::IntegerDomain::NonNegative => "non_negative", + super::IntegerDomain::Unsigned8 => "unsigned_8", + super::IntegerDomain::Unsigned16 => "unsigned_16", + super::IntegerDomain::NonNegativeI32 => "non_negative_i32", + super::IntegerDomain::Unsigned32 => "unsigned_32", + super::IntegerDomain::InputTokenSemantics => { + "input_token_semantics" + } + }; + serde_json::json!([ + column.name, + storage, + column.nullable, + integer_domain + ]) }).collect::>(), "validator": validator, "parents": spec.parents, @@ -2301,6 +2531,13 @@ mod tests { enum InvalidRestoreCase { ProviderJson, StorageClass, + NegativeSortIndex, + BooleanDomain, + Unsigned8Domain, + Unsigned16Domain, + NonNegativeI32Domain, + Unsigned32Domain, + InputTokenSemanticsDomain, DuplicateProvider, DuplicateEndpoint, ForeignKeyOrphan, @@ -2308,9 +2545,16 @@ mod tests { } impl InvalidRestoreCase { - const ALL: [Self; 6] = [ + const ALL: [Self; 13] = [ Self::ProviderJson, Self::StorageClass, + Self::NegativeSortIndex, + Self::BooleanDomain, + Self::Unsigned8Domain, + Self::Unsigned16Domain, + Self::NonNegativeI32Domain, + Self::Unsigned32Domain, + Self::InputTokenSemanticsDomain, Self::DuplicateProvider, Self::DuplicateEndpoint, Self::ForeignKeyOrphan, @@ -2321,6 +2565,13 @@ mod tests { match self { Self::ProviderJson => "provider-json", Self::StorageClass => "storage-class", + Self::NegativeSortIndex => "negative-sort-index", + Self::BooleanDomain => "boolean-domain", + Self::Unsigned8Domain => "unsigned-8-domain", + Self::Unsigned16Domain => "unsigned-16-domain", + Self::NonNegativeI32Domain => "non-negative-i32-domain", + Self::Unsigned32Domain => "unsigned-32-domain", + Self::InputTokenSemanticsDomain => "input-token-semantics-domain", Self::DuplicateProvider => "duplicate-provider", Self::DuplicateEndpoint => "duplicate-endpoint", Self::ForeignKeyOrphan => "foreign-key-orphan", @@ -2352,6 +2603,64 @@ mod tests { [], )?; } + InvalidRestoreCase::NegativeSortIndex => { + source.execute( + "UPDATE providers SET sort_index = -1 WHERE id = 'remote-provider'", + [], + )?; + } + InvalidRestoreCase::BooleanDomain => { + source.execute( + "UPDATE providers SET in_failover_queue = 2 + WHERE id = 'remote-provider'", + [], + )?; + } + InvalidRestoreCase::Unsigned8Domain => { + source.execute( + "UPDATE proxy_config SET max_retries = 256 + WHERE app_type = 'claude'", + [], + )?; + } + InvalidRestoreCase::Unsigned16Domain => { + source.execute( + "UPDATE proxy_config SET listen_port = 65536 + WHERE app_type = 'claude'", + [], + )?; + } + InvalidRestoreCase::NonNegativeI32Domain => { + source.execute( + "UPDATE proxy_config SET streaming_first_byte_timeout = 2147483648 + WHERE app_type = 'claude'", + [], + )?; + } + InvalidRestoreCase::Unsigned32Domain => { + source.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, input_tokens, + latency_ms, status_code, created_at + ) VALUES ( + 'domain-u32', 'remote-provider', 'pi', 'model', + 4294967296, 1, 200, 1 + )", + [], + )?; + } + InvalidRestoreCase::InputTokenSemanticsDomain => { + source.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_token_semantics, latency_ms, status_code, created_at + ) VALUES ( + 'domain-token-semantics', 'remote-provider', 'pi', 'model', + 3, 1, 200, 1 + )", + [], + )?; + } InvalidRestoreCase::DuplicateProvider => { source.execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) @@ -2491,6 +2800,40 @@ mod tests { Ok(()) } + #[test] + #[serial] + fn sql_and_binary_restore_preserve_incremental_auto_vacuum() -> Result<(), AppError> { + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create auto-vacuum restore home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let target = Database::init()?; + let source = canonical_restore_source()?; + let auto_vacuum = || -> Result { + let conn = crate::database::lock_conn!(target.conn); + conn.query_row("PRAGMA auto_vacuum", [], |row| row.get(0)) + .map_err(|error| AppError::Database(error.to_string())) + }; + + assert_eq!(auto_vacuum()?, 2); + run_restore_entry( + &target, + &source, + RestoreEntryPoint::Sql, + "unused-auto-vacuum.db", + )?; + assert_eq!(auto_vacuum()?, 2); + run_restore_entry( + &target, + &source, + RestoreEntryPoint::Binary, + "binary-auto-vacuum.db", + )?; + assert_eq!(auto_vacuum()?, 2); + Ok(()) + } + #[test] #[serial] fn public_restore_entries_discard_hostile_schema_and_publish_only_canonical_objects( @@ -2642,6 +2985,20 @@ mod tests { VALUES (9001, 'remote-provider', 'pi', 'https://null.invalid', NULL, NULL)", [], )?; + source.execute( + "INSERT INTO provider_endpoints + (id, provider_id, app_type, url, added_at, last_used) + VALUES (-9001, 'remote-provider', 'pi', 'https://negative-id.invalid', NULL, NULL)", + [], + )?; + source.execute( + "INSERT INTO stream_check_logs ( + id, provider_id, provider_name, app_type, status, success, message, tested_at + ) VALUES ( + -9002, 'remote-provider', 'Remote Provider', 'pi', 'ok', 1, 'ok', 1 + )", + [], + )?; for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() @@ -2676,6 +3033,20 @@ mod tests { restored, (9001, None, None, settings.to_string(), meta.to_string()) ); + let negative_endpoint_id: i64 = conn.query_row( + "SELECT id FROM provider_endpoints + WHERE url = 'https://negative-id.invalid'", + [], + |row| row.get(0), + )?; + let negative_stream_id: i64 = conn.query_row( + "SELECT id FROM stream_check_logs + WHERE provider_id = 'remote-provider'", + [], + |row| row.get(0), + )?; + assert_eq!(negative_endpoint_id, -9001); + assert_eq!(negative_stream_id, -9002); conn.execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) @@ -2865,6 +3236,142 @@ mod tests { Ok(()) } + #[test] + #[serial] + fn restore_safety_backup_and_publish_hold_one_live_write_boundary() -> Result<(), AppError> { + use crate::database::NewProviderAggregate; + use crate::provider::ProviderMutationInput; + use serde_json::json; + use std::sync::{mpsc, Arc, TryLockError}; + use std::time::Duration; + + fn input(id: &str, name: &str) -> ProviderMutationInput { + ProviderMutationInput { + id: id.to_string(), + name: name.to_string(), + settings_config: json!({"env": {}}), + website_url: None, + category: None, + created_at: Some(1_700_000_000), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create restore write-boundary home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let database = Database::init()?; + database.create_provider(NewProviderAggregate::from_input( + "pi", + input("before-restore", "Before Restore"), + )?)?; + + let backup_dir = crate::config::get_app_config_dir().join("backups"); + fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; + let source_path = backup_dir.join("write-boundary-source.db"); + let source = canonical_restore_source()?; + let mut destination = Connection::open(&source_path)?; + { + let backup = Backup::new(&source, &mut destination)?; + backup.step(-1)?; + } + drop(destination); + + let database = Arc::new(database); + let restore_database = Arc::clone(&database); + let (safety_ready_tx, safety_ready_rx) = mpsc::channel(); + let (release_restore_tx, release_restore_rx) = mpsc::channel(); + let restore = std::thread::spawn(move || { + super::TEST_AFTER_SAFETY_BACKUP.with(|slot| { + *slot.borrow_mut() = Some(Box::new(move || { + safety_ready_tx.send(()).expect("signal safety-backup seam"); + release_restore_rx + .recv() + .expect("release restore after seam assertion"); + })); + }); + restore_database + .restore_from_backup("write-boundary-source.db") + .map_err(|error| error.to_string()) + }); + + safety_ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("restore reached the post-safety-backup seam"); + assert!(matches!( + database.conn.try_lock(), + Err(TryLockError::WouldBlock) + )); + + let writer_database = Arc::clone(&database); + let (writer_started_tx, writer_started_rx) = mpsc::channel(); + let writer = std::thread::spawn(move || -> Result<(), String> { + writer_started_tx + .send(()) + .map_err(|error| error.to_string())?; + let aggregate = + NewProviderAggregate::from_input("pi", input("late-writer", "Late Writer")) + .map_err(|error| error.to_string())?; + writer_database + .create_provider(aggregate) + .map_err(|error| error.to_string()) + }); + writer_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("concurrent writer started"); + release_restore_tx + .send(()) + .expect("release restore publication"); + + let safety_id = restore + .join() + .expect("restore thread did not panic") + .map_err(AppError::Message)?; + writer + .join() + .expect("writer thread did not panic") + .map_err(AppError::Message)?; + assert!(!safety_id.is_empty()); + + let live_counts: (i64, i64, i64) = { + let conn = crate::database::lock_conn!(database.conn); + conn.query_row( + "SELECT + (SELECT COUNT(*) FROM providers + WHERE id = 'before-restore' AND app_type = 'pi'), + (SELECT COUNT(*) FROM providers + WHERE id = 'remote-provider' AND app_type = 'pi'), + (SELECT COUNT(*) FROM providers + WHERE id = 'late-writer' AND app_type = 'pi')", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )? + }; + assert_eq!(live_counts, (0, 1, 1)); + + let safety = Connection::open(backup_dir.join(format!("{safety_id}.db")))?; + let safety_counts: (i64, i64, i64) = safety.query_row( + "SELECT + (SELECT COUNT(*) FROM providers + WHERE id = 'before-restore' AND app_type = 'pi'), + (SELECT COUNT(*) FROM providers + WHERE id = 'remote-provider' AND app_type = 'pi'), + (SELECT COUNT(*) FROM providers + WHERE id = 'late-writer' AND app_type = 'pi')", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(safety_counts, (1, 0, 0)); + Ok(()) + } + #[test] #[serial] fn restore_file_size_limits_accept_n_and_publicly_reject_n_plus_one() -> Result<(), AppError> { diff --git a/src-tauri/src/database/backup_restore_certification.rs b/src-tauri/src/database/backup_restore_certification.rs new file mode 100644 index 000000000..93ab3b0f3 --- /dev/null +++ b/src-tauri/src/database/backup_restore_certification.rs @@ -0,0 +1,609 @@ +#![cfg(test)] +//! 前置工程 B:Canonical Restore 认证测试套件 v1(测试先行) +//! +//! 规则与前置 A 完全一致(docs/pi-support-restructure-zh.md):实现方不得 +//! 修改本文件;异议上报裁决;全绿是盲审前置条件而非充分条件。 +//! +//! ## 与前置 A 的衔接(解冻声明) +//! 前置 A 套件以 SHA-256 冻结了 schema.rs/migration.rs/backup.rs。本前置 +//! 工程的实现必然修改它们,因此:**pre-B 实现期间,pre-A 的 +//! `certify_infra_files_frozen_until_preproject_b` 预期为红**,这是裁决内 +//! 的解冻,不是违规;pre-B 认证通过后由裁决方以终态哈希重冻基线。除此之外 +//! pre-A 的其余 34 项必须全程保持绿——restore 改动破坏写面契约同样是失败。 +//! +//! ## 范围界定 +//! 既有 backup.rs 测试模块已实现修正案 2 E2 的大部分义务(恶意 schema 双 +//! 入口、user_version 哨兵矩阵、VM/页预算、符号链接/FIFO/大小边界、NULL 与 +//! 显式 ID 保真、canonical 预发布契约)。本套件不重复它们,而是: +//! 1. 绑定冻结这些既有测试(删除/改名/空壳化即红); +//! 2. 固化 R4 盲审确认、至今未修的缺口为可执行红灯; +//! 3. 以结构断言钉死类型屏障与搬运禁令。 +//! +//! ## 义务清单(实现方交付,非本文件可执行部分) +//! O1【safety 窗口】`restore_from_backup` 必须自 safety backup 创建前起持有 +//! live 写边界直至 publish 完成——期间任何并发写都不得既错过 safety +//! backup 又被 publish 覆盖。实现后补 seam 级确定性测试并上报并入。 +//! **盲审重点核查项**:逐行核查 restore_from_backup 的锁范围。 +//! O2【binary TOCTOU】文件解析链(backups 目录文件名解析 → 预检 +//! symlink_metadata → O_NOFOLLOW 打开 → 打开后 fstat 身份复核)必须闭合, +//! 路径任何组件在检查后不得再经 symlink 重解析。**盲审重点核查项**。 +//! O3 pre-B 认证通过后,裁决方重冻 infra 基线并将本套件红灯清单归零存档。 +//! +//! ## 交接时的预期红绿 +//! 应红 2:`certify_imported_sort_index_domain_is_enforced`(值域缺口: +//! RestoreColumnSpec 只查 storage/nullable,负数 sort_index 可发布,生产端 +//! Option 读取即败——R4 finding)、 +//! `certify_incremental_auto_vacuum_survives_restore`(canonical stage 未 +//! 继承 INCREMENTAL,publish 后 live 文件退化为 NONE——R4 finding)。 +//! 其余应绿。任何偏离(非清单红、应红变绿、编译失败)立即上报。 +//! +//! ## 残余风险与收口 +//! 沿用前置 A 的收口边界与残余清单(动态 SQL、trigger/view、Backup API、 +//! `#[path]` 等);本套件新增接受项:导出路径(`dump_sql`)的 `SELECT *` +//! 属导出语义,不在搬运禁令内;转移函数命名若脱离 +//! restore/stage/scratch/publish/transfer/canonical 词根,搬运禁令扫描不到, +//! 由盲审兜底。清单外新绕过按盲审 finding 处理,不再扩充扫描器。 + +use crate::database::Database; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::visit::{self, Visit}; +use syn::{Attribute, ImplItem, Item, Lit, Meta}; + +// --------------------------------------------------------------------------- +// 基建(与前置 A 同构;各套件自持,保持契约文件独立) +// --------------------------------------------------------------------------- + +fn source_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn backup_source() -> String { + fs::read_to_string(source_root().join("database/backup.rs")).expect("read backup.rs") +} + +fn schema_source() -> String { + fs::read_to_string(source_root().join("database/schema.rs")).expect("read schema.rs") +} + +fn cfg_expr_requires_test(expr: &str) -> bool { + let expr = expr.trim(); + if expr == "test" { + return true; + } + let strip = |name: &str| -> Option<&str> { + expr.strip_prefix(name) + .and_then(|rest| rest.trim_start().strip_prefix('(')) + .and_then(|rest| rest.strip_suffix(')')) + }; + if let Some(args) = strip("all") { + return args.split(',').any(cfg_expr_requires_test); + } + if let Some(args) = strip("any") { + let parts: Vec<&str> = args.split(',').collect(); + return !parts.is_empty() && parts.iter().all(|part| cfg_expr_requires_test(part)); + } + false +} + +fn attrs_mark_test_only(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attribute| { + attribute.path().is_ident("cfg") + && matches!( + &attribute.meta, + Meta::List(list) if cfg_expr_requires_test(&list.tokens.to_string()) + ) + }) +} + +/// 按函数收集生产字符串字面量(cfg-aware),供搬运禁令做函数级作用域判定。 +#[derive(Default)] +struct FnLiteralCollector { + current: Vec, + per_fn: Vec<(String, Vec)>, +} + +impl FnLiteralCollector { + fn enter_fn(&mut self, name: String, block: &syn::Block) { + let saved = std::mem::take(&mut self.current); + self.visit_block(block); + let literals = std::mem::replace(&mut self.current, saved); + self.per_fn.push((name, literals)); + } +} + +impl<'ast> Visit<'ast> for FnLiteralCollector { + fn visit_item(&mut self, item: &'ast Item) { + match item { + Item::Fn(function) if !attrs_mark_test_only(&function.attrs) => { + self.enter_fn(function.sig.ident.to_string(), &function.block); + } + Item::Impl(item_impl) if !attrs_mark_test_only(&item_impl.attrs) => { + for impl_item in &item_impl.items { + let ImplItem::Fn(function) = impl_item else { + continue; + }; + if !attrs_mark_test_only(&function.attrs) { + self.enter_fn(function.sig.ident.to_string(), &function.block); + } + } + } + Item::Mod(item_mod) if !attrs_mark_test_only(&item_mod.attrs) => { + if let Some((_, nested)) = &item_mod.content { + for nested_item in nested { + self.visit_item(nested_item); + } + } + } + _ => {} + } + } + + fn visit_expr_lit(&mut self, expression: &'ast syn::ExprLit) { + if let Lit::Str(literal) = &expression.lit { + self.current.push(literal.value()); + } + visit::visit_expr_lit(self, expression); + } +} + +fn find_fn<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemFn> { + for item in items { + match item { + Item::Fn(function) if function.sig.ident == name => return Some(function), + Item::Mod(item_mod) => { + let nested = item_mod.content.as_ref().map(|(_, items)| items.as_slice()); + if let Some(found) = nested.and_then(|items| find_fn(items, name)) { + return Some(found); + } + } + _ => {} + } + } + None +} + +fn find_impl_fn_signature(source: &syn::File, name: &str) -> Option { + for item in &source.items { + let Item::Impl(item_impl) = item else { + continue; + }; + for impl_item in &item_impl.items { + let ImplItem::Fn(function) = impl_item else { + continue; + }; + if function.sig.ident == name { + let mut signature = String::new(); + for input in &function.sig.inputs { + if let syn::FnArg::Typed(typed) = input { + let mut probe = IdentProbe::default(); + probe.visit_type(&typed.ty); + signature.push_str(&probe.found.into_iter().collect::>().join(",")); + signature.push(';'); + } + } + return Some(signature); + } + } + } + None +} + +#[derive(Default)] +struct IdentProbe { + found: BTreeSet, +} + +impl<'ast> Visit<'ast> for IdentProbe { + fn visit_ident(&mut self, identifier: &'ast syn::Ident) { + self.found.insert(identifier.to_string()); + } + + // syn 的默认遍历不将方法名作为 ident 访问;绑定探针必须能看见 + // `database.restore_from_backup(...)` 这类方法调用(裁决修正)。 + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + self.found.insert(node.method.to_string()); + visit::visit_expr_method_call(self, node); + } + + // syn 同样不遍历宏 token,而测试体大量使用 assert!(...) 包裹调用 + // (backup.rs:2828 的 restore_from_backup 即在其中)。与前置 A 扫描器 + // 的宏提取同源:把宏 token 按标识符切词纳入探针(裁决修正,第三层)。 + fn visit_macro(&mut self, mac: &'ast syn::Macro) { + for word in mac + .tokens + .to_string() + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + { + if !word.is_empty() { + self.found.insert(word.to_string()); + } + } + visit::visit_macro(self, mac); + } +} + +struct TestHomeGuard(Option); + +impl TestHomeGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var_os("CC_SWITCH_TEST_HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", path); + Self(previous) + } +} + +impl Drop for TestHomeGuard { + fn drop(&mut self) { + match self.0.take() { + Some(previous) => std::env::set_var("CC_SWITCH_TEST_HOME", previous), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } +} + +// --------------------------------------------------------------------------- +// S1:类型屏障——publish 只接受 CanonicalStage,工厂外不可构造 +// --------------------------------------------------------------------------- + +#[test] +fn certify_publish_consumes_only_canonical_stage() { + let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs"); + let signature = find_impl_fn_signature(&syntax, "publish_canonical_stage") + .expect("publish_canonical_stage exists"); + assert!( + signature.contains("CanonicalStage"), + "publish must consume CanonicalStage, got param types: {signature}" + ); + assert!( + !signature.contains("UntrustedScratch") && !signature.contains("Connection"), + "publish must not accept raw connections or untrusted scratch: {signature}" + ); + + // CanonicalStage 字段必须全私有:只有 schema.rs 的 current-schema 工厂 + // 能构造,输入派生的 schema 在类型上无路可发布。 + let schema = syn::parse_file(&schema_source()).expect("parse schema.rs"); + fn find_struct<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemStruct> { + for item in items { + match item { + Item::Struct(item_struct) if item_struct.ident == name => return Some(item_struct), + Item::Mod(item_mod) => { + let nested = item_mod.content.as_ref().map(|(_, items)| items.as_slice()); + if let Some(found) = nested.and_then(|items| find_struct(items, name)) { + return Some(found); + } + } + _ => {} + } + } + None + } + let stage = find_struct(&schema.items, "CanonicalStage").expect("CanonicalStage in schema.rs"); + for field in &stage.fields { + assert!( + matches!(field.vis, syn::Visibility::Inherited), + "CanonicalStage fields must stay private to the schema factory" + ); + } + // 禁止旁路转换:backup.rs/schema.rs 不得存在 UntrustedScratch → + // CanonicalStage 的 From/Into 实现。 + for source in [backup_source(), schema_source()] { + let parsed = syn::parse_file(&source).expect("parse restore sources"); + for item in &parsed.items { + let Item::Impl(item_impl) = item else { + continue; + }; + let Some((_, trait_path, _)) = &item_impl.trait_ else { + continue; + }; + let is_conversion = trait_path + .segments + .last() + .is_some_and(|segment| segment.ident == "From" || segment.ident == "Into"); + if !is_conversion { + continue; + } + let mut probe = IdentProbe::default(); + probe.visit_item_impl(item_impl); + assert!( + !(probe.found.contains("UntrustedScratch") + && probe.found.contains("CanonicalStage")), + "no conversion between UntrustedScratch and CanonicalStage may exist" + ); + } + } +} + +// --------------------------------------------------------------------------- +// S2:搬运禁令——restore 侧函数不得使用通配/冲突改写动词 +// --------------------------------------------------------------------------- + +#[test] +fn certify_restore_transfer_forbids_wildcard_and_conflict_verbs() { + let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs"); + let mut collector = FnLiteralCollector::default(); + for item in &syntax.items { + collector.visit_item(item); + } + let restore_scoped = |name: &str| { + [ + "restore", + "stage", + "scratch", + "publish", + "transfer", + "canonical", + "copy_", + ] + .iter() + .any(|root| name.contains(root)) + }; + // 核心搬运函数必须在扫描视野内(改名脱离词根即红,防止禁令被绕空)。 + assert!( + collector + .per_fn + .iter() + .any(|(name, _)| name == "copy_fixed_table"), + "the fixed-column transfer function 'copy_fixed_table' must exist and be scanned" + ); + let mut violations = Vec::new(); + for (name, literals) in &collector.per_fn { + if !restore_scoped(name) { + continue; + } + for literal in literals { + let upper = literal.to_ascii_uppercase(); + for banned in [ + "SELECT *", + "INSERT OR IGNORE", + "INSERT OR REPLACE", + "ON CONFLICT DO UPDATE", + "REPLACE INTO", + ] { + if upper.contains(banned) { + violations.push(format!("{name}: {banned}")); + } + } + } + } + assert!( + violations.is_empty(), + "restore-side transfer must use fixed column lists and plain INSERT:\n{}", + violations.join("\n") + ); +} + +#[test] +fn certify_no_wal_sidecar_manipulation() { + let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs"); + let mut collector = FnLiteralCollector::default(); + for item in &syntax.items { + collector.visit_item(item); + } + for (name, literals) in &collector.per_fn { + for literal in literals { + assert!( + !literal.contains("-wal") && !literal.contains("-shm"), + "production fn '{name}' must not touch WAL/SHM sidecar files; \ + all publication goes through the SQLite Backup API" + ); + } + } +} + +#[test] +fn certify_untrusted_scratch_hardening_idents_present() { + // 存在性绑定:NOFOLLOW 打开与打开后身份复核的关键符号不得被移除。 + let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs"); + let mut probe = IdentProbe::default(); + for item in &syntax.items { + if let Item::Fn(function) = item { + probe.visit_item_fn(function); + } + if let Item::Impl(item_impl) = item { + probe.visit_item_impl(item_impl); + } + if let Item::Struct(item_struct) = item { + probe.visit_item_struct(item_struct); + } + } + for required in ["O_NOFOLLOW", "SQLITE_OPEN_NOFOLLOW", "symlink_metadata"] { + assert!( + probe.found.contains(required), + "scratch hardening symbol '{required}' disappeared from backup.rs" + ); + } +} + +// --------------------------------------------------------------------------- +// S3:绑定冻结既有认证级测试(删除/改名/空壳化即红) +// --------------------------------------------------------------------------- + +#[test] +fn certify_bound_restore_tests_present() { + let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs"); + for (required, must_reference) in [ + ( + "restore_policy_snapshot_is_exhaustive_and_detects_missing_table_fixture", + "RESTORE_TABLE_SPECS", + ), + ( + "sql_and_binary_restore_share_canonical_prepublication_contracts", + "assert_weak_ledgers_are_rebuilt", + ), + ( + "public_restore_entries_discard_hostile_schema_and_publish_only_canonical_objects", + "run_restore_entry", + ), + ( + "public_restore_entries_abort_invalid_rows_without_live_or_ledger_mutation", + "run_restore_entry", + ), + ( + "public_restore_entries_preserve_nulls_unknown_json_and_explicit_ids", + "run_restore_entry", + ), + ( + "every_supported_user_version_has_a_public_migration_sentinel", + "SCHEMA_VERSION", + ), + ( + "import_rejects_cross_file_statements_and_leaves_no_file_behind", + "import_sql_string", + ), + ( + "public_sql_restore_discards_input_trigger_before_local_copy", + "import_sql_string", + ), + ( + "public_file_restore_entries_reject_symlink_directory_and_fifo", + "restore_from_backup", + ), + ( + "restore_file_size_limits_accept_n_and_publicly_reject_n_plus_one", + "MAX_BINARY_RESTORE_BYTES", + ), + ( + "public_restore_entries_enforce_vm_and_page_budgets", + "RestoreLimitGuard", + ), + ("import_still_accepts_a_genuine_export", "export_sql_string"), + ( + "publish_copies_device_local_ledgers_at_the_commit_boundary", + "UntrustedScratch", + ), + ] { + let function = find_fn(&syntax.items, required) + .unwrap_or_else(|| panic!("bound restore test '{required}' is missing")); + assert!( + function + .attrs + .iter() + .any(|attribute| attribute.path().is_ident("test")), + "'{required}' must be a #[test] function" + ); + let mut probe = IdentProbe::default(); + probe.visit_block(&function.block); + assert!( + probe.found.contains(must_reference), + "'{required}' must exercise '{must_reference}' (empty stubs cannot pass)" + ); + } +} + +// --------------------------------------------------------------------------- +// R1(应红):导入值域——负 sort_index 不得发布 +// --------------------------------------------------------------------------- + +#[test] +fn certify_imported_sort_index_domain_is_enforced() { + use crate::database::NewProviderAggregate; + use crate::provider::ProviderMutationInput; + use serde_json::json; + + let database = Database::memory().expect("memory db"); + let input = ProviderMutationInput { + id: "domain-probe".to_string(), + name: "值域探针".to_string(), + settings_config: json!({"env": {}}), + website_url: None, + category: None, + created_at: Some(1_700_000_000), + sort_index: Some(7777), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + }; + database + .create_provider(NewProviderAggregate::from_input("claude", input).expect("build")) + .expect("create"); + let exported = database.export_sql_string().expect("export"); + assert!( + exported.contains("7777"), + "export must contain the sort_index sentinel" + ); + let hostile = exported.replace("7777", "-1"); + + let target = Database::memory().expect("target db"); + let err = target + .import_sql_string(&hostile) + .expect_err("negative sort_index must abort the import: production reads Option"); + let message = err.to_string(); + assert!( + !message.is_empty(), + "domain violation must surface a real error" + ); + // 值域违规必须整体中止:目标库不得出现该 provider 的任何残留。 + let conn = target.conn.lock().expect("lock target"); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM providers WHERE id = 'domain-probe'", + [], + |row| row.get(0), + ) + .expect("count probe"); + assert_eq!( + count, 0, + "aborted import must leave no partial provider row" + ); +} + +// --------------------------------------------------------------------------- +// R2(应红):publish 后 live 文件必须保持 INCREMENTAL auto-vacuum +// --------------------------------------------------------------------------- + +#[test] +#[serial_test::serial] +fn certify_incremental_auto_vacuum_survives_restore() { + let home = tempfile::tempdir().expect("temp home"); + let _guard = TestHomeGuard::set(home.path()); + let database = Database::init().expect("file-backed db"); + // 空库导出会被 import 的基本状态校验拒绝("未包含有效的供应商或 MCP + // 数据"),导致本测试在错误的位置变红;必须先播种最小数据,使红灯 + // 精确落在 auto_vacuum 断言上、并在实现修复后能够转绿(裁决修正)。 + { + use crate::database::NewProviderAggregate; + use crate::provider::ProviderMutationInput; + use serde_json::json; + let input = ProviderMutationInput { + id: "vacuum-probe".to_string(), + name: "auto-vacuum 探针".to_string(), + settings_config: json!({"env": {}}), + website_url: None, + category: None, + created_at: Some(1_700_000_000), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + }; + database + .create_provider(NewProviderAggregate::from_input("claude", input).expect("build")) + .expect("seed provider"); + } + let auto_vacuum_of = |db: &Database| -> i64 { + let conn = db.conn.lock().expect("lock"); + conn.query_row("PRAGMA auto_vacuum", [], |row| row.get(0)) + .expect("read auto_vacuum") + }; + assert_eq!( + auto_vacuum_of(&database), + 2, + "baseline: production file DB is created with INCREMENTAL auto-vacuum" + ); + let exported = database.export_sql_string().expect("export"); + database + .import_sql_string(&exported) + .expect("roundtrip import of a genuine export"); + assert_eq!( + auto_vacuum_of(&database), + 2, + "publish must not silently downgrade the live file to auto_vacuum=NONE \ + (canonical stage must inherit INCREMENTAL before its first page is written)" + ); +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 23a05284a..e22883377 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -24,6 +24,8 @@ //! ``` pub(crate) mod backup; +#[cfg(test)] +mod backup_restore_certification; mod dao; mod migration; mod schema; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 5e542b8c7..6dd246a8c 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -517,7 +517,8 @@ impl Database { Connection::open(file.path()).map_err(|error| AppError::Database(error.to_string()))?; connection .execute_batch( - "PRAGMA foreign_keys = ON; + "PRAGMA auto_vacuum = INCREMENTAL; + PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF;", ) .map_err(|error| AppError::Database(error.to_string()))?; diff --git a/tests/fixtures/pi/restore-policy-v1.json b/tests/fixtures/pi/restore-policy-v1.json index 7e8ff29ed..21c74a502 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": "c0a680de97b4d3eb291114ebfdf50fdfd65ce7f63819a0bc84f97cec6466bf07", + "specSha256": "c0bf1a34172de0b4d63ca597973c6126d8d5cbba42e73c0b7a7ff043f2a58cdb", "tables": [ {"name": "providers", "policy": "portable_incoming"}, {"name": "provider_endpoints", "policy": "portable_incoming"},