From 89961dff28533718588fb0c055d4fe325d7cd582 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sat, 1 Aug 2026 14:12:08 +0000 Subject: [PATCH] fix(database): close canonical restore review gaps --- src-tauri/src/database/backup.rs | 935 ++++++++++++++++++++--- src-tauri/src/database/dao/providers.rs | 29 +- src-tauri/src/database/dao/settings.rs | 25 +- tests/fixtures/pi/restore-policy-v1.json | 4 +- 4 files changed, 902 insertions(+), 91 deletions(-) diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 1fb66aaf8..95bf0adc6 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -11,7 +11,7 @@ use rusqlite::backup::{Backup, StepResult}; use rusqlite::config::DbConfig; use rusqlite::limits::Limit; use rusqlite::types::{Value, ValueRef}; -use rusqlite::{Connection, OpenFlags}; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; use std::fs::{self, File, Metadata, OpenOptions}; use std::io::{Read, Take, Write}; use std::path::{Path, PathBuf}; @@ -86,7 +86,7 @@ const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"]; /// /// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在 /// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在 -/// `validate_basic_state` 之前,导入即使最终失败,文件也已经被创建;而 `settings` +/// canonical data validation 之前,导入即使最终失败,文件也已经被创建;而 `settings` /// 表不在同步 skip/commit-boundary overlay 之列,WebDAV/S3 同步会走 /// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。 /// @@ -154,13 +154,25 @@ const SYNC_SKIP_TABLES: &[&str] = &[ "provider_health", "proxy_live_backup", "usage_daily_rollups", + "session_log_sync", "pi_provider_projections", "skill_deployments", ]; /// Exact file/deployment ownership belongs to the current device. Portable SQL /// exports carry table shape but never rows, and imports restore live rows. -const DEVICE_LOCAL_TABLES: &[&str] = &["pi_provider_projections", "skill_deployments"]; +const DEVICE_LOCAL_TABLES: &[&str] = &[ + "session_log_sync", + "pi_provider_projections", + "skill_deployments", +]; + +/// Gateway credentials are installation-local even though legacy releases +/// stored them in the otherwise-portable key/value table. Neither SQL export +/// flavor nor an untrusted restore source may transfer these rows. The live +/// value is copied at the publication boundary until its owning subsystem has +/// durably migrated it out of SQLite. +const DEVICE_LOCAL_SETTING_KEYS: &[&str] = &["claude_desktop_gateway_token", "pi_gateway_token"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RestorePolicy { @@ -188,6 +200,7 @@ enum IntegerDomain { Unrestricted, Boolean, NonNegative, + SortIndex, Unsigned8, Unsigned16, NonNegativeI32, @@ -287,7 +300,7 @@ const PROVIDERS_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[ nullable_text_col!("website_url"), nullable_text_col!("category"), nullable_integer_col!("created_at", Unrestricted), - nullable_integer_col!("sort_index", NonNegative), + nullable_integer_col!("sort_index", SortIndex), nullable_text_col!("notes"), nullable_text_col!("icon"), nullable_text_col!("icon_color"), @@ -620,7 +633,7 @@ const RESTORE_TABLE_SPECS: &[RestoreTableSpec] = &[ }, RestoreTableSpec { name: "session_log_sync", - policy: RestorePolicy::PortableIncoming, + policy: RestorePolicy::PreserveLive, columns: SESSION_SYNC_RESTORE_COLUMNS, validator: RestoreRowValidator::OpaqueStorage, parents: &[], @@ -757,6 +770,91 @@ fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) +} + +#[cfg(windows)] +fn open_backup_directory(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + OpenOptions::new() + .read(true) + // Omitting FILE_SHARE_DELETE keeps the opened directory from being + // renamed or replaced while its child is resolved and copied. + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_backup_directory(path: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "anchored backup-directory opens are unsupported on this platform: {}", + path.display() + ), + )) +} + +#[cfg(unix)] +fn open_backup_child( + directory: &File, + _directory_path: &Path, + filename: &std::ffi::OsStr, +) -> std::io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let filename = std::ffi::CString::new(filename.as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + filename.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + Err(std::io::Error::last_os_error()) + } else { + // SAFETY: openat returned a new owned descriptor and this branch + // transfers its sole ownership into File. + Ok(unsafe { File::from_raw_fd(descriptor) }) + } +} + +#[cfg(windows)] +fn open_backup_child( + _directory: &File, + directory_path: &Path, + filename: &std::ffi::OsStr, +) -> std::io::Result { + open_nofollow(&directory_path.join(filename)) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_backup_child( + _directory: &File, + _directory_path: &Path, + _filename: &std::ffi::OsStr, +) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "anchored backup-child opens are unsupported on this platform", + )) +} + fn open_validated_restore_source( path: &Path, max_bytes: u64, @@ -835,6 +933,56 @@ fn validate_regular_file(path: &Path, max_bytes: u64) -> Result Result<(), AppError> { + let path = Path::new(filename); + let mut components = path.components(); + let exactly_one_normal_component = matches!( + (components.next(), components.next()), + (Some(std::path::Component::Normal(name)), None) if name == path.as_os_str() + ); + if filename.is_empty() + || filename.contains('\0') + || filename.contains(':') + || !filename.ends_with(".db") + || !exactly_one_normal_component + { + return Err(AppError::InvalidInput( + "Invalid backup filename".to_string(), + )); + } + Ok(()) +} + +fn open_validated_backup_directory(path: &Path) -> Result { + let directory = open_backup_directory(path).map_err(|error| AppError::io(path, error))?; + let metadata = directory + .metadata() + .map_err(|error| AppError::io(path, error))?; + if !metadata.file_type().is_dir() { + return Err(AppError::InvalidInput(format!( + "backup directory must be a non-symlink directory: {}", + path.display() + ))); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(AppError::InvalidInput(format!( + "backup directory must not be a reparse point: {}", + path.display() + ))); + } + } + Ok(directory) +} + +fn validate_backup_directory(path: &Path) -> Result<(), AppError> { + open_validated_backup_directory(path).map(|_| ()) +} + fn read_restore_file(path: &Path, max_bytes: u64) -> Result, AppError> { let (mut file, opened) = open_validated_restore_source(path, max_bytes, "restore source changed before open")?; @@ -861,11 +1009,30 @@ 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 (mut source, opened) = open_validated_restore_source( - path, - max_bytes, - "binary restore source changed before open", - )?; + let directory_path = path.parent().ok_or_else(|| { + AppError::InvalidInput(format!( + "binary restore source has no parent directory: {}", + path.display() + )) + })?; + let filename = path.file_name().ok_or_else(|| { + AppError::InvalidInput(format!( + "binary restore source has no filename: {}", + path.display() + )) + })?; + let directory = open_validated_backup_directory(directory_path)?; + let mut source = open_backup_child(&directory, directory_path, filename) + .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 { + return Err(AppError::InvalidInput(format!( + "binary restore source must be a bounded regular file: {}", + path.display() + ))); + } let mut owned = NamedTempFile::new().map_err(|error| AppError::IoContext { context: "create owned binary restore snapshot".to_string(), @@ -887,7 +1054,35 @@ fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result Result { self.enforce_scratch_size()?; + self.constrain_scratch_growth()?; let version = Database::get_user_version(&self.connection)?; if version > SCHEMA_VERSION { return Err(AppError::InvalidInput(format!( @@ -1057,6 +1253,41 @@ impl UntrustedScratch { Ok(self) } + /// SQLite's Backup API adopts the source page size but keeps the + /// destination's page-count limit as a raw number. A 64 KiB source would + /// therefore turn the nominal 2 GiB scratch ceiling into a 32 GiB growth + /// allowance unless the limit is rebound after cloning. + fn constrain_scratch_growth(&self) -> Result<(), AppError> { + let page_size: u64 = self + .connection + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|error| AppError::Database(error.to_string()))?; + if page_size == 0 { + return Err(AppError::InvalidInput( + "restore scratch reported a zero page size".to_string(), + )); + } + let byte_bounded_pages = MAX_SCRATCH_BYTES / page_size; + let requested = max_page_count().min(byte_bounded_pages); + if requested == 0 { + return Err(AppError::InvalidInput( + "restore scratch page size exceeds the byte budget".to_string(), + )); + } + let applied: u64 = self + .connection + .query_row(&format!("PRAGMA max_page_count = {requested}"), [], |row| { + row.get(0) + }) + .map_err(|error| AppError::Database(error.to_string()))?; + if applied > requested { + return Err(AppError::InvalidInput(format!( + "restore scratch already exceeds its {requested}-page growth budget" + ))); + } + Ok(()) + } + fn drop_untrusted_executable_objects(&self) -> Result<(), AppError> { for schema in ["sqlite_schema", "sqlite_temp_schema"] { let mut stmt = self @@ -1162,6 +1393,9 @@ fn validate_integer_domain( IntegerDomain::Unrestricted => true, IntegerDomain::Boolean => matches!(*value, 0 | 1), IntegerDomain::NonNegative => *value >= 0, + IntegerDomain::SortIndex => { + (0..i64::MAX).contains(value) && usize::try_from(*value).is_ok() + } 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), @@ -1297,6 +1531,10 @@ fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), Ok(()) } +fn is_device_local_setting_key(key: &str) -> bool { + DEVICE_LOCAL_SETTING_KEYS.contains(&key) +} + fn copy_fixed_table( source: &Connection, target: &Connection, @@ -1337,6 +1575,17 @@ fn copy_fixed_table( .map(|index| row.get::<_, Value>(index)) .collect::, _>>() .map_err(|error| AppError::InvalidInput(error.to_string()))?; + if spec.name == "settings" + && values + .first() + .and_then(|value| match value { + Value::Text(key) => Some(key.as_str()), + _ => None, + }) + .is_some_and(is_device_local_setting_key) + { + continue; + } validate_restore_row(spec, &values)?; target .execute(&insert, rusqlite::params_from_iter(values.iter())) @@ -1350,6 +1599,31 @@ fn copy_fixed_table( Ok(()) } +fn copy_live_device_settings(source: &Connection, target: &Connection) -> Result<(), AppError> { + for key in DEVICE_LOCAL_SETTING_KEYS { + let value = source + .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { + row.get::<_, Option>(0) + }) + .optional() + .map_err(|error| AppError::Database(error.to_string()))? + .flatten(); + if let Some(value) = value { + target + .execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2)", + rusqlite::params![key, value], + ) + .map_err(|error| { + AppError::Database(format!( + "copy device-local setting at restore boundary: {error}" + )) + })?; + } + } + Ok(()) +} + fn assert_restore_policy_topology() -> Result<(), AppError> { let mut seen = std::collections::BTreeSet::new(); for spec in RESTORE_TABLE_SPECS { @@ -1456,18 +1730,35 @@ fn validate_canonical_behaviors(conn: &Connection) -> Result<(), AppError> { |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; + let (probe_upper, probe_lower, probe_app) = loop { + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let upper = format!("__restore_A{suffix}"); + let lower = format!("__restore_a{suffix}"); + let app = format!("__restore_probe_{suffix}"); + let existing: i64 = transaction + .query_row( + "SELECT COUNT(*) FROM providers + WHERE app_type = ?1 AND id IN (?2, ?3)", + rusqlite::params![app, upper, lower], + |row| row.get(0), + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if existing == 0 { + break (upper, lower, app); + } + }; transaction .execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) - VALUES ('__restore_Aa', '__probe', 'probe', '{}', '{}')", - [], + VALUES (?1, ?2, 'probe', '{}', '{}')", + rusqlite::params![probe_upper, probe_app], ) .map_err(|error| AppError::Database(error.to_string()))?; transaction .execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) - VALUES ('__restore_aa', '__probe', 'probe', '{}', '{}')", - [], + VALUES (?1, ?2, 'probe', '{}', '{}')", + rusqlite::params![probe_lower, probe_app], ) .map_err(|error| { AppError::Database(format!( @@ -1478,8 +1769,8 @@ fn validate_canonical_behaviors(conn: &Connection) -> Result<(), AppError> { .execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at, last_used) - VALUES ('__restore_Aa', '__probe', 'https://probe.invalid', NULL, NULL)", - [], + VALUES (?1, ?2, 'https://probe.invalid', NULL, NULL)", + rusqlite::params![probe_upper, probe_app], ) .map_err(|error| AppError::Database(error.to_string()))?; let endpoint_id = transaction.last_insert_rowid(); @@ -1492,8 +1783,8 @@ fn validate_canonical_behaviors(conn: &Connection) -> Result<(), AppError> { if transaction .execute( "INSERT INTO providers (id, app_type, name, settings_config, meta) - VALUES ('__restore_Aa', '__probe', 'replacement', '{}', '{}')", - [], + VALUES (?1, ?2, 'replacement', '{}', '{}')", + rusqlite::params![probe_upper, probe_app], ) .is_ok() { @@ -1504,8 +1795,8 @@ fn validate_canonical_behaviors(conn: &Connection) -> Result<(), AppError> { let endpoint_count: i64 = transaction .query_row( "SELECT COUNT(*) FROM provider_endpoints - WHERE provider_id = '__restore_Aa' AND app_type = '__probe'", - [], + WHERE provider_id = ?1 AND app_type = ?2", + rusqlite::params![probe_upper, probe_app], |row| row.get(0), ) .map_err(|error| AppError::Database(error.to_string()))?; @@ -1697,7 +1988,6 @@ impl Database { transaction .commit() .map_err(|error| AppError::Database(error.to_string()))?; - Self::validate_basic_state(stage.connection())?; validate_canonical_stage(&stage)?; Ok(stage) } @@ -1729,6 +2019,7 @@ impl Database { copy_fixed_table(&main_conn, &transaction, spec, true)?; } } + copy_live_device_settings(&main_conn, &transaction)?; transaction .commit() .map_err(|error| AppError::Database(error.to_string()))?; @@ -1868,12 +2159,28 @@ impl Database { return Err(error); } - Self::cleanup_db_backups(&backup_dir)?; + let completed = + open_nofollow(&backup_path).map_err(|error| AppError::io(&backup_path, error))?; + Self::cleanup_db_backups(&backup_dir, Some(&backup_path))?; + let still_current = + open_nofollow(&backup_path).map_err(|error| AppError::io(&backup_path, error))?; + if !still_current + .metadata() + .map_err(|error| AppError::io(&backup_path, error))? + .file_type() + .is_file() + || !same_open_file_identity(&completed, &still_current) + .map_err(|error| AppError::io(&backup_path, error))? + { + return Err(AppError::InvalidInput( + "completed safety backup changed during retention cleanup".to_string(), + )); + } Ok(Some(backup_path)) } /// 清理旧的数据库备份,保留最新的 N 个 - fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> { + fn cleanup_db_backups(dir: &Path, protected: Option<&Path>) -> Result<(), AppError> { let retain = crate::settings::effective_backup_retain_count(); let entries = match fs::read_dir(dir) { Ok(iter) => iter @@ -1894,7 +2201,10 @@ impl Database { } let remove_count = entries.len().saturating_sub(retain); - let mut sorted = entries; + let mut sorted = entries + .into_iter() + .filter(|entry| protected.is_none_or(|path| entry.path() != path)) + .collect::>(); sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); for entry in sorted.into_iter().take(remove_count) { @@ -1905,25 +2215,35 @@ impl Database { Ok(()) } - /// 基础状态校验 - fn validate_basic_state(conn: &Connection) -> Result<(), AppError> { - let provider_count: i64 = conn - .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - let mcp_count: i64 = conn - .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - if provider_count == 0 && mcp_count == 0 { - return Err(AppError::Config( - "导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(), - )); - } - Ok(()) - } - /// 导出数据库为 SQL 文本 fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result { + let mut device_local_secrets = Vec::new(); + let has_settings: bool = conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM sqlite_schema + WHERE type = 'table' AND name = 'settings' + )", + [], + |row| row.get(0), + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if has_settings { + for key in DEVICE_LOCAL_SETTING_KEYS { + if let Some(value) = conn + .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { + row.get::<_, Option>(0) + }) + .optional() + .map_err(|error| AppError::Database(error.to_string()))? + .flatten() + .filter(|value| !value.is_empty()) + { + device_local_secrets.push(value); + } + } + } + let mut output = String::new(); let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); let user_version: i64 = conn @@ -1987,6 +2307,18 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + if table == "settings" + && row + .get_ref(0) + .ok() + .and_then(|value| match value { + ValueRef::Text(key) => std::str::from_utf8(key).ok(), + _ => None, + }) + .is_some_and(is_device_local_setting_key) + { + continue; + } let mut values = Vec::with_capacity(columns.len()); for idx in 0..columns.len() { let value = row @@ -2008,6 +2340,17 @@ impl Database { } output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n"); + if DEVICE_LOCAL_SETTING_KEYS + .iter() + .any(|key| output.contains(key)) + || device_local_secrets + .iter() + .any(|secret| output.contains(secret)) + { + return Err(AppError::Database( + "portable SQL export contained a device-local credential".to_string(), + )); + } Ok(output) } @@ -2089,18 +2432,10 @@ impl Database { /// Restore database from a backup file. Returns the safety backup ID. pub fn restore_from_backup(&self, filename: &str) -> Result { - // Security: validate filename to prevent path traversal - if filename.contains("..") - || filename.contains('/') - || filename.contains('\\') - || !filename.ends_with(".db") - { - return Err(AppError::InvalidInput( - "Invalid backup filename".to_string(), - )); - } + validate_backup_filename(filename)?; let backup_dir = get_app_config_dir().join("backups"); + validate_backup_directory(&backup_dir)?; let backup_path = backup_dir.join(filename); // Build a canonical data-only stage before touching the live database. @@ -2120,16 +2455,7 @@ impl Database { /// Rename a backup file. Returns the new filename. pub fn rename_backup(old_filename: &str, new_name: &str) -> Result { - // Validate old filename (path traversal + .db suffix) - if old_filename.contains("..") - || old_filename.contains('/') - || old_filename.contains('\\') - || !old_filename.ends_with(".db") - { - return Err(AppError::InvalidInput( - "Invalid backup filename".to_string(), - )); - } + validate_backup_filename(old_filename)?; // Clean new name let trimmed = new_name.trim(); @@ -2148,19 +2474,17 @@ impl Database { } // Prevent path traversal in new name - if name_part.contains("..") - || name_part.contains('/') - || name_part.contains('\\') - || name_part.contains('\0') - { + if name_part.contains('\0') || name_part.contains(':') { return Err(AppError::InvalidInput( "Invalid characters in new name".to_string(), )); } let new_filename = format!("{name_part}.db"); + validate_backup_filename(&new_filename)?; let backup_dir = get_app_config_dir().join("backups"); + validate_backup_directory(&backup_dir)?; let old_path = backup_dir.join(old_filename); let new_path = backup_dir.join(&new_filename); @@ -2183,18 +2507,10 @@ impl Database { /// Delete a backup file permanently. pub fn delete_backup(filename: &str) -> Result<(), AppError> { - // Validate filename (path traversal + .db suffix) - if filename.contains("..") - || filename.contains('/') - || filename.contains('\\') - || !filename.ends_with(".db") - { - return Err(AppError::InvalidInput( - "Invalid backup filename".to_string(), - )); - } - - let backup_path = get_app_config_dir().join("backups").join(filename); + validate_backup_filename(filename)?; + let backup_dir = get_app_config_dir().join("backups"); + validate_backup_directory(&backup_dir)?; + let backup_path = backup_dir.join(filename); if !backup_path.exists() { return Err(AppError::InvalidInput(format!( "Backup file not found: {filename}" @@ -2217,7 +2533,7 @@ mod tests { TEST_MAX_BACKUP_TRANSIENT_RETRIES, TEST_MAX_PAGE_COUNT, TEST_MAX_VM_STEPS, }; use crate::error::AppError; - use crate::settings::{update_settings, AppSettings}; + use crate::settings::{get_settings, update_settings, AppSettings}; use rusqlite::backup::Backup; use rusqlite::Connection; use serial_test::serial; @@ -2288,6 +2604,24 @@ mod tests { } } + struct AppSettingsGuard(AppSettings); + + impl AppSettingsGuard { + fn replace(settings: AppSettings) -> Result { + let previous = get_settings(); + update_settings(settings)?; + Ok(Self(previous)) + } + } + + impl Drop for AppSettingsGuard { + fn drop(&mut self) { + if let Err(error) = update_settings(self.0.clone()) { + log::error!("failed to restore test settings: {error}"); + } + } + } + fn restore_policy_snapshot() -> serde_json::Value { let full_specs = RESTORE_TABLE_SPECS .iter() @@ -2323,6 +2657,7 @@ mod tests { super::IntegerDomain::Unrestricted => "unrestricted", super::IntegerDomain::Boolean => "boolean", super::IntegerDomain::NonNegative => "non_negative", + super::IntegerDomain::SortIndex => "sort_index", super::IntegerDomain::Unsigned8 => "unsigned_8", super::IntegerDomain::Unsigned16 => "unsigned_16", super::IntegerDomain::NonNegativeI32 => "non_negative_i32", @@ -2655,6 +2990,7 @@ mod tests { ProfilePayloadShape, StorageClass, NegativeSortIndex, + MaxSortIndex, BooleanDomain, Unsigned8Domain, Unsigned16Domain, @@ -2662,6 +2998,7 @@ mod tests { Unsigned32Domain, InputTokenSemanticsDomain, NegativeProviderMultiplier, + NegativeProviderMetaLimit, InvalidProviderPricingSource, NegativeProxyMultiplier, InvalidProxyPricingSource, @@ -2673,12 +3010,13 @@ mod tests { } impl InvalidRestoreCase { - const ALL: [Self; 20] = [ + const ALL: [Self; 22] = [ Self::ProviderJson, Self::McpTagsShape, Self::ProfilePayloadShape, Self::StorageClass, Self::NegativeSortIndex, + Self::MaxSortIndex, Self::BooleanDomain, Self::Unsigned8Domain, Self::Unsigned16Domain, @@ -2686,6 +3024,7 @@ mod tests { Self::Unsigned32Domain, Self::InputTokenSemanticsDomain, Self::NegativeProviderMultiplier, + Self::NegativeProviderMetaLimit, Self::InvalidProviderPricingSource, Self::NegativeProxyMultiplier, Self::InvalidProxyPricingSource, @@ -2703,6 +3042,7 @@ mod tests { Self::ProfilePayloadShape => "profile-payload-shape", Self::StorageClass => "storage-class", Self::NegativeSortIndex => "negative-sort-index", + Self::MaxSortIndex => "max-sort-index", Self::BooleanDomain => "boolean-domain", Self::Unsigned8Domain => "unsigned-8-domain", Self::Unsigned16Domain => "unsigned-16-domain", @@ -2710,6 +3050,7 @@ mod tests { Self::Unsigned32Domain => "unsigned-32-domain", Self::InputTokenSemanticsDomain => "input-token-semantics-domain", Self::NegativeProviderMultiplier => "negative-provider-multiplier", + Self::NegativeProviderMetaLimit => "negative-provider-meta-limit", Self::InvalidProviderPricingSource => "invalid-provider-pricing-source", Self::NegativeProxyMultiplier => "negative-proxy-multiplier", Self::InvalidProxyPricingSource => "invalid-proxy-pricing-source", @@ -2769,6 +3110,13 @@ mod tests { [], )?; } + InvalidRestoreCase::MaxSortIndex => { + source.execute( + "UPDATE providers SET sort_index = 9223372036854775807 + WHERE id = 'remote-provider'", + [], + )?; + } InvalidRestoreCase::BooleanDomain => { source.execute( "UPDATE providers SET in_failover_queue = 2 @@ -2828,6 +3176,13 @@ mod tests { [], )?; } + InvalidRestoreCase::NegativeProviderMetaLimit => { + source.execute( + "UPDATE providers SET meta = '{\"limitDailyUsd\":\"-1\"}' + WHERE id = 'remote-provider'", + [], + )?; + } InvalidRestoreCase::InvalidProviderPricingSource => { source.execute( "UPDATE providers SET meta = '{\"pricingModelSource\":\"invalid\"}' @@ -3319,8 +3674,54 @@ mod tests { assert_eq!(restored, 1, "actual oldest-layout migration sentinel"); } + // Exercise the real immediately-previous v16 shape. In particular, + // these columns and device-local tables did not exist yet; relabeling + // a v17 database cannot prove that the migration supplies them. + for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] + .into_iter() + .enumerate() + { + let previous = canonical_restore_source()?; + previous.execute_batch( + "INSERT INTO provider_endpoints + (provider_id, app_type, url, added_at, last_used) + VALUES ('remote-provider', 'pi', 'https://v16.invalid', 16, NULL); + INSERT INTO skills ( + id, name, directory, enabled_codex, enabled_pi, + installed_at, updated_at + ) VALUES ('actual-v16-skill', 'Actual v16 Skill', '/v16', 1, 0, 16, 16); + PRAGMA foreign_keys = OFF; + DROP TABLE pi_provider_projections; + DROP TABLE skill_deployments; + ALTER TABLE provider_endpoints DROP COLUMN last_used; + ALTER TABLE skills DROP COLUMN enabled_pi; + PRAGMA user_version = 16;", + )?; + let target = Database::memory()?; + run_restore_entry( + &target, + &previous, + entry_point, + &format!("actual-v16-{entry_index}.db"), + )?; + let conn = crate::database::lock_conn!(target.conn); + let sentinel: (Option, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT last_used FROM provider_endpoints + WHERE provider_id = 'remote-provider' + AND app_type = 'pi' + AND url = 'https://v16.invalid'), + (SELECT enabled_pi FROM skills WHERE id = 'actual-v16-skill'), + (SELECT COUNT(*) FROM pi_provider_projections), + (SELECT COUNT(*) FROM skill_deployments)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + assert_eq!(sentinel, (None, 0, 0, 0)); + } + // Every version still gets a public dispatch sentinel. The separate - // historical-layout case above prevents this matrix from passing only + // historical-layout cases above prevent 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()?; @@ -3411,7 +3812,7 @@ mod tests { let result = db.import_sql_string(&malicious); assert!(result.is_err(), "{label} 必须被拒绝"); - // 光报错不够:文件创建发生在 prepare 之后、`validate_basic_state` 之前, + // 光报错不够:文件创建发生在 prepare 之后、canonical validation 之前, // 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。 assert!( !target.exists(), @@ -3467,6 +3868,34 @@ mod tests { let _home_guard = TestHomeGuard::set(test_home.path()); let database = Database::memory()?; let backup_dir = crate::config::get_app_config_dir().join("backups"); + for filename in ["C:outside.db", "backup.db:stream.db", "../outside.db"] { + assert!( + database.restore_from_backup(filename).is_err(), + "restore filename must be exactly one portable normal component" + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let external = test_home.path().join("external-backups"); + fs::create_dir_all(&external).map_err(|error| AppError::io(&external, error))?; + fs::create_dir_all( + backup_dir + .parent() + .ok_or_else(|| AppError::InvalidInput("backup parent missing".to_string()))?, + ) + .map_err(|error| AppError::io(&backup_dir, error))?; + fs::write(external.join("outside.db"), b"outside") + .map_err(|error| AppError::io(&external, error))?; + symlink(&external, &backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; + assert!( + database.restore_from_backup("outside.db").is_err(), + "a symlinked backup directory must not authorize an outside source" + ); + fs::remove_file(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; + } fs::create_dir_all(&backup_dir).map_err(|error| AppError::io(&backup_dir, error))?; let sql_directory = test_home.path().join("sql-directory"); @@ -3698,6 +4127,85 @@ mod tests { Ok(()) } + #[test] + #[serial] + fn retention_never_removes_the_just_completed_safety_backup() -> Result<(), AppError> { + use std::fs::FileTimes; + use std::time::{Duration, SystemTime}; + + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create safety-retention home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let mut settings = get_settings(); + settings.backup_retain_count = Some(1); + let _settings_guard = AppSettingsGuard::replace(settings)?; + + 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-retention', 'pi', 'Live', '{}', '{}')", + [], + )?; + } + 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("future-source.db"); + let source = canonical_restore_source()?; + let mut destination = Connection::open(&source_path)?; + { + let backup = Backup::new(&source, &mut destination)?; + super::run_backup_to_completion(&backup, "prepare future-mtime restore source")?; + } + drop(destination); + File::open(&source_path) + .and_then(|file| { + file.set_times( + FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)), + ) + }) + .map_err(|error| AppError::io(&source_path, error))?; + + let safety_id = database.restore_from_backup("future-source.db")?; + let safety_path = backup_dir.join(format!("{safety_id}.db")); + assert!( + safety_path.is_file(), + "retention=1 must retain the safety backup even when the source mtime is newer" + ); + let safety = Connection::open(&safety_path)?; + let old_live: i64 = safety.query_row( + "SELECT COUNT(*) FROM providers + WHERE id = 'live-before-retention' AND app_type = 'pi'", + [], + |row| row.get(0), + )?; + assert_eq!(old_live, 1); + + let equal_dir = test_home.path().join("equal-mtime-retention"); + fs::create_dir(&equal_dir).map_err(|error| AppError::io(&equal_dir, error))?; + let protected = equal_dir.join("protected.db"); + let peer = equal_dir.join("peer.db"); + fs::write(&protected, b"protected").map_err(|error| AppError::io(&protected, error))?; + fs::write(&peer, b"peer").map_err(|error| AppError::io(&peer, error))?; + let equal_time = FileTimes::new().set_modified(SystemTime::UNIX_EPOCH); + File::open(&protected) + .and_then(|file| file.set_times(equal_time)) + .map_err(|error| AppError::io(&protected, error))?; + File::open(&peer) + .and_then(|file| file.set_times(equal_time)) + .map_err(|error| AppError::io(&peer, error))?; + Database::cleanup_db_backups(&equal_dir, Some(&protected))?; + assert!( + protected.is_file(), + "equal mtime must not defeat protection" + ); + assert!(!peer.exists()); + Ok(()) + } + #[test] #[serial] fn incomplete_safety_backup_is_rejected_and_removed() -> Result<(), AppError> { @@ -3934,6 +4442,37 @@ mod tests { "binary page budget must reject oversized scratch growth" ); } + + let large_page_path = backup_dir.join("page-size-64k.db"); + let large_page_source = Connection::open(&large_page_path)?; + large_page_source.execute_batch("PRAGMA page_size = 65536; VACUUM;")?; + Database::create_tables_on_conn(&large_page_source)?; + Database::apply_schema_migrations_on_conn(&large_page_source)?; + large_page_source.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('large-page-source', 'pi', 'Large Page', '{}', '{}')", + [], + )?; + let source_page_size: u64 = + large_page_source.query_row("PRAGMA page_size", [], |row| row.get(0))?; + assert_eq!(source_page_size, 65_536); + drop(large_page_source); + + let scratch = UntrustedScratch::from_binary(&large_page_path)?; + let scratch_page_size: u64 = + scratch + .connection + .query_row("PRAGMA page_size", [], |row| row.get(0))?; + let scratch_page_limit: u64 = + scratch + .connection + .query_row("PRAGMA max_page_count", [], |row| row.get(0))?; + assert_eq!(scratch_page_size, 65_536); + assert!( + scratch_page_size.saturating_mul(scratch_page_limit) <= MAX_SCRATCH_BYTES, + "binary cloning must rebind the page-count limit to the adopted page size" + ); + database.restore_from_backup("page-size-64k.db")?; Ok(()) } @@ -3965,6 +4504,206 @@ mod tests { Ok(()) } + #[test] + #[serial] + fn empty_canonical_backups_restore_through_sql_and_binary_entries() -> Result<(), AppError> { + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create empty restore home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let empty = Database::memory()?.snapshot_to_memory()?; + + for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] + .into_iter() + .enumerate() + { + let target = Database::memory()?; + { + let conn = crate::database::lock_conn!(target.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('to-be-cleared', 'pi', 'Old', '{}', '{}')", + [], + )?; + } + run_restore_entry( + &target, + &empty, + entry_point, + &format!("empty-canonical-{index}.db"), + )?; + let conn = crate::database::lock_conn!(target.conn); + let counts: (i64, i64) = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM providers), + (SELECT COUNT(*) FROM mcp_servers)", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + assert_eq!(counts, (0, 0)); + } + Ok(()) + } + + #[test] + #[serial] + fn canonical_behavior_probe_never_claims_legal_provider_keys() -> Result<(), AppError> { + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create behavior-probe restore home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let source = Database::memory()?; + { + let conn = crate::database::lock_conn!(source.conn); + conn.execute_batch( + "INSERT INTO providers (id, app_type, name, settings_config, meta) VALUES + ('__restore_Aa', '__probe', 'Upper', '{}', '{}'), + ('__restore_aa', '__probe', 'Lower', '{}', '{}');", + )?; + } + let source = source.snapshot_to_memory()?; + + for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] + .into_iter() + .enumerate() + { + let target = Database::memory()?; + run_restore_entry( + &target, + &source, + entry_point, + &format!("legal-probe-keys-{index}.db"), + )?; + let conn = crate::database::lock_conn!(target.conn); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM providers + WHERE app_type = '__probe' + AND id IN ('__restore_Aa', '__restore_aa')", + [], + |row| row.get(0), + )?; + assert_eq!(count, 2); + } + Ok(()) + } + + #[test] + #[serial] + fn gateway_credentials_are_never_portable_and_live_values_survive_both_restore_entries( + ) -> Result<(), AppError> { + let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create device-credential restore home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(test_home.path()); + let remote = Database::memory()?; + { + let conn = crate::database::lock_conn!(remote.conn); + conn.execute_batch( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('remote', 'pi', 'Remote', '{}', '{}'); + INSERT INTO settings (key, value) VALUES + ('portable-setting', 'remote-portable'), + ('claude_desktop_gateway_token', 'remote-claude-secret'), + ('pi_gateway_token', 'remote-pi-secret'); + INSERT INTO session_log_sync + (file_path, last_modified, last_line_offset, last_synced_at) + VALUES ('/device/session.jsonl', 900, 900, 900);", + )?; + } + + for exported in [ + remote.export_sql_string()?, + remote.export_sql_string_for_sync()?, + ] { + for forbidden in [ + "claude_desktop_gateway_token", + "pi_gateway_token", + "remote-claude-secret", + "remote-pi-secret", + "/device/session.jsonl", + ] { + assert!( + !exported.contains(forbidden), + "portable export leaked device credential material" + ); + } + assert!(exported.contains("remote-portable")); + } + + let remote_snapshot = remote.snapshot_to_memory()?; + for (index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] + .into_iter() + .enumerate() + { + let target = Database::memory()?; + { + let conn = crate::database::lock_conn!(target.conn); + conn.execute_batch( + "INSERT INTO settings (key, value) VALUES + ('claude_desktop_gateway_token', 'local-claude-secret'), + ('pi_gateway_token', 'local-pi-secret'); + INSERT INTO session_log_sync + (file_path, last_modified, last_line_offset, last_synced_at) + VALUES ('/device/session.jsonl', 10, 20, 30);", + )?; + } + + match entry_point { + RestoreEntryPoint::Sql => { + let mut sql = Database::dump_sql(&remote_snapshot, &[])?; + sql = sql.replacen( + "COMMIT;", + "INSERT INTO settings (key, value) VALUES + ('claude_desktop_gateway_token', 'remote-claude-secret'); + INSERT INTO settings (key, value) VALUES + ('pi_gateway_token', 'remote-pi-secret'); + COMMIT;", + 1, + ); + target.import_sql_string(&sql)?; + } + RestoreEntryPoint::Binary => { + run_restore_entry( + &target, + &remote_snapshot, + RestoreEntryPoint::Binary, + &format!("device-credentials-{index}.db"), + )?; + } + } + + let conn = crate::database::lock_conn!(target.conn); + let values: (String, String, String) = conn.query_row( + "SELECT + (SELECT value FROM settings + WHERE key = 'claude_desktop_gateway_token'), + (SELECT value FROM settings WHERE key = 'pi_gateway_token'), + (SELECT value FROM settings WHERE key = 'portable-setting')", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!( + values, + ( + "local-claude-secret".to_string(), + "local-pi-secret".to_string(), + "remote-portable".to_string() + ) + ); + let cursor: (i64, i64, i64) = conn.query_row( + "SELECT last_modified, last_line_offset, last_synced_at + FROM session_log_sync WHERE file_path = '/device/session.jsonl'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(cursor, (10, 20, 30)); + } + Ok(()) + } + #[test] fn portable_export_and_import_preserve_device_local_pi_ledgers() -> Result<(), AppError> { let source = Database::memory()?; @@ -4135,8 +4874,21 @@ mod tests { VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')", [], )?; + conn.execute( + "INSERT INTO session_log_sync + (file_path, last_modified, last_line_offset, last_synced_at) + VALUES ('/same/device/session.jsonl', 900, 900, 900)", + [], + )?; } + let portable_sql = remote_db.export_sql_string()?; let remote_sql = remote_db.export_sql_string_for_sync()?; + for exported in [&portable_sql, &remote_sql] { + assert!( + !exported.contains("/same/device/session.jsonl"), + "device-local session cursor must not be portable" + ); + } let local_db = Database::memory()?; { @@ -4169,6 +4921,12 @@ mod tests { ) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)", [], )?; + conn.execute( + "INSERT INTO session_log_sync + (file_path, last_modified, last_line_offset, last_synced_at) + VALUES ('/same/device/session.jsonl', 10, 20, 30)", + [], + )?; } local_db.import_sql_string_for_sync(&remote_sql)?; @@ -4186,7 +4944,7 @@ mod tests { "remote config should be imported" ); - let (request_logs, rollups, stream_logs): (i64, i64, i64) = { + let (request_logs, rollups, stream_logs, cursor): (i64, i64, i64, (i64, i64, i64)) = { let conn = crate::database::lock_conn!(local_db.conn); let request_logs = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| { @@ -4200,7 +4958,13 @@ mod tests { conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| { row.get(0) })?; - (request_logs, rollups, stream_logs) + let cursor = conn.query_row( + "SELECT last_modified, last_line_offset, last_synced_at + FROM session_log_sync WHERE file_path = '/same/device/session.jsonl'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + (request_logs, rollups, stream_logs, cursor) }; assert_eq!(request_logs, 1, "local request logs should be preserved"); assert_eq!(rollups, 1, "local rollups should be preserved"); @@ -4208,6 +4972,7 @@ mod tests { stream_logs, 1, "local stream check logs should be preserved" ); + assert_eq!(cursor, (10, 20, 30), "local session cursor must win"); Ok(()) } diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 8537da90c..a53d18723 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -100,6 +100,23 @@ pub(crate) fn validate_provider_storage_json( if let Some(source) = meta.pricing_model_source.as_deref() { super::proxy::validate_pricing_source(source)?; } + for (field, value) in [ + ("limitDailyUsd", meta.limit_daily_usd.as_deref()), + ("limitMonthlyUsd", meta.limit_monthly_usd.as_deref()), + ] { + if let Some(value) = value { + let parsed = value.parse::().map_err(|error| { + AppError::InvalidInput(format!( + "invalid provider meta {field} for '{app_type}/{provider_id}': {error}" + )) + })?; + if parsed < rust_decimal::Decimal::ZERO { + return Err(AppError::InvalidInput(format!( + "negative provider meta {field} for '{app_type}/{provider_id}'" + ))); + } + } + } Ok(()) } @@ -495,7 +512,17 @@ impl Database { |row| row.get(0), ) .map_err(|e| AppError::Database(e.to_string()))?; - Ok(max.map(|v| (v + 1) as usize).unwrap_or(0)) + match max { + Some(value) => value + .checked_add(1) + .and_then(|next| usize::try_from(next).ok()) + .ok_or_else(|| { + AppError::InvalidInput(format!( + "provider sort_index cannot advance past {value}" + )) + }), + None => Ok(0), + } } /// 启动时调用:补齐缺失的官方预设供应商(Claude / Codex / Gemini)。 diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 85524e80b..e4d5c3b70 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -25,9 +25,8 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { - Ok(Some( - row.get(0).map_err(|e| AppError::Database(e.to_string()))?, - )) + row.get::<_, Option>(0) + .map_err(|e| AppError::Database(e.to_string())) } else { Ok(None) } @@ -325,3 +324,23 @@ impl Database { self.set_setting("log_config", &json) } } + +#[cfg(test)] +mod tests { + use crate::database::{lock_conn, Database}; + use crate::error::AppError; + + #[test] + fn null_setting_value_hydrates_as_absent() -> Result<(), crate::error::AppError> { + let database = Database::memory()?; + { + let conn = lock_conn!(database.conn); + conn.execute( + "INSERT INTO settings (key, value) VALUES ('nullable-setting', NULL)", + [], + )?; + } + assert_eq!(database.get_setting("nullable-setting")?, None); + Ok(()) + } +} diff --git a/tests/fixtures/pi/restore-policy-v1.json b/tests/fixtures/pi/restore-policy-v1.json index b411df23f..f320737f8 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": "e2b44f6fbbb1ca7287487e68aeb5684853c024978f418be688fcd15d665fd3aa", + "specSha256": "f5f04260bd45bc2cd6f4766d2c66f445dad685766c1950872bc626262f113907", "tables": [ {"name": "providers", "policy": "portable_incoming"}, {"name": "provider_endpoints", "policy": "portable_incoming"}, @@ -23,7 +23,7 @@ {"name": "stream_check_logs", "policy": "portable_incoming"}, {"name": "proxy_live_backup", "policy": "portable_incoming"}, {"name": "usage_daily_rollups", "policy": "portable_incoming"}, - {"name": "session_log_sync", "policy": "portable_incoming"}, + {"name": "session_log_sync", "policy": "preserve_live"}, {"name": "profiles", "policy": "portable_incoming"}, {"name": "pi_provider_projections", "policy": "preserve_live"}, {"name": "skill_deployments", "policy": "preserve_live"}