From f35b47c1cf070b6b524184be14d6d1b9b89ecfd8 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Mon, 3 Aug 2026 13:16:52 +0000 Subject: [PATCH] fix(pi): reject unsafe binary ownership restore --- src-tauri/src/commands/import_export.rs | 11 +- .../src/database/dao/pi_portable_state.rs | 183 +++++++++++++++++- src-tauri/src/services/skill_deployment.rs | 127 ++++++++++++ 3 files changed, 315 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index dfa8303b9..d2832fd46 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -197,11 +197,12 @@ pub async fn restore_db_backup( .await .map_err(|error| format!("Restore preparation failed: {error}"))?; - let restore_result = - tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename)) - .await - .map_err(|error| AppError::Message(format!("Restore task failed: {error}"))) - .and_then(|result| result); + let restore_result = tauri::async_runtime::spawn_blocking(move || { + PiSkillDeploymentService::restore_binary_backup_without_pi_ownership(&db, &filename) + }) + .await + .map_err(|error| AppError::Message(format!("Restore task failed: {error}"))) + .and_then(|result| result); let restored = match restore_result { Ok(restored) => restored, Err(error) => { diff --git a/src-tauri/src/database/dao/pi_portable_state.rs b/src-tauri/src/database/dao/pi_portable_state.rs index 056607292..0d3381265 100644 --- a/src-tauri/src/database/dao/pi_portable_state.rs +++ b/src-tauri/src/database/dao/pi_portable_state.rs @@ -10,7 +10,7 @@ use crate::database::SkillDeploymentMethod; use crate::database::{lock_conn, Database, PiProviderProjection, SkillDeployment}; use crate::error::AppError; -use rusqlite::params; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; use std::fs; use std::path::Path; @@ -63,6 +63,58 @@ impl Database { Ok(backup_id) } + /// Fail closed before the legacy whole-database restore can import + /// device-local Pi ownership evidence. + /// + /// Canonical binary restore hardening is a separate project. Until that + /// boundary can retain the receiving device's ledgers atomically, binary + /// restore is supported only when neither side carries ownership rows. + pub(crate) fn ensure_binary_restore_has_no_pi_ownership( + &self, + filename: &str, + ) -> Result<(), AppError> { + if filename.contains("..") + || filename.contains('/') + || filename.contains('\\') + || !filename.ends_with(".db") + { + return Err(AppError::InvalidInput( + "Invalid backup filename".to_string(), + )); + } + + { + let conn = lock_conn!(self.conn); + if pi_device_local_rows_exist(&conn)? { + return Err(binary_restore_ownership_error( + "当前数据库", + "current database", + )); + } + } + + let backup_path = crate::config::get_app_config_dir() + .join("backups") + .join(filename); + if !backup_path.exists() { + return Err(AppError::InvalidInput(format!( + "Backup file not found: {filename}" + ))); + } + let source = Connection::open_with_flags( + &backup_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| AppError::Database(format!("无法只读检查备份: {error}")))?; + if pi_device_local_rows_exist(&source)? { + return Err(binary_restore_ownership_error( + "所选备份", + "selected backup", + )); + } + Ok(()) + } + fn capture_pi_device_local_state(&self) -> Result { let conn = lock_conn!(self.conn); @@ -183,6 +235,60 @@ impl Database { } } +fn pi_device_local_rows_exist(conn: &Connection) -> Result { + let table_exists = |name: &str| -> Result { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + [name], + |_| Ok(()), + ) + .optional() + .map(|row| row.is_some()) + .map_err(|error| AppError::Database(error.to_string())) + }; + + let projections_exist = if table_exists("pi_provider_projections")? { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM pi_provider_projections LIMIT 1)", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| AppError::Database(error.to_string()))? + } else { + false + }; + if projections_exist { + return Ok(true); + } + + if table_exists("skill_deployments")? { + return conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM skill_deployments + WHERE app_type = 'pi' + LIMIT 1 + )", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| AppError::Database(error.to_string())); + } + Ok(false) +} + +fn binary_restore_ownership_error(source_zh: &str, source_en: &str) -> AppError { + AppError::localized( + "pi.binary_restore_device_ownership_unsupported", + format!( + "为防止历史设备所有权记录覆盖当前 Pi 原生文件,{source_zh}含 Pi 所有权状态时不能使用数据库备份恢复;请改用可移植 SQL 导入" + ), + format!( + "Binary database restore is unavailable because the {source_en} contains device-local Pi ownership state; use portable SQL import instead" + ), + ) +} + /// Remove complete INSERT statements for the two device-local tables from SQL /// generated by `Database::dump_sql`. Values may contain quotes, semicolons, or /// newlines, so line filtering is insufficient; statement boundaries are found @@ -352,4 +458,79 @@ mod tests { ); Ok(()) } + + #[test] + fn binary_restore_guard_rejects_live_ownership_before_opening_the_backup( + ) -> Result<(), AppError> { + let db = Database::memory()?; + db.claim_pi_projection_key("local-provider", "local-key")?; + + let error = db + .ensure_binary_restore_has_no_pi_ownership("missing.db") + .expect_err("live ownership must reject before inspecting a source"); + assert!(error.to_string().contains("portable SQL")); + Ok(()) + } + + #[test] + fn binary_restore_guard_rejects_backup_ownership_but_allows_empty_tables( + ) -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let empty_path = temp.path().join("empty.db"); + let owned_path = temp.path().join("owned.db"); + for path in [&empty_path, &owned_path] { + let conn = + Connection::open(path).map_err(|error| AppError::Database(error.to_string()))?; + conn.execute_batch( + "CREATE TABLE pi_provider_projections ( + provider_id TEXT PRIMARY KEY, + provider_key TEXT NOT NULL + ); + CREATE TABLE skill_deployments ( + app_type TEXT NOT NULL + );", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + } + let owned = + Connection::open(&owned_path).map_err(|error| AppError::Database(error.to_string()))?; + owned + .execute( + "INSERT INTO pi_provider_projections (provider_id, provider_key) + VALUES ('historical-provider', 'native-key')", + [], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + + let empty = Connection::open_with_flags( + &empty_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| AppError::Database(error.to_string()))?; + assert!(!pi_device_local_rows_exist(&empty)?); + let owned = Connection::open_with_flags( + &owned_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| AppError::Database(error.to_string()))?; + assert!(pi_device_local_rows_exist(&owned)?); + drop(owned); + + let owned = + Connection::open(&owned_path).map_err(|error| AppError::Database(error.to_string()))?; + owned + .execute("DELETE FROM pi_provider_projections", []) + .map_err(|error| AppError::Database(error.to_string()))?; + owned + .execute("INSERT INTO skill_deployments (app_type) VALUES ('pi')", []) + .map_err(|error| AppError::Database(error.to_string()))?; + drop(owned); + let skill_only = Connection::open_with_flags( + &owned_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| AppError::Database(error.to_string()))?; + assert!(pi_device_local_rows_exist(&skill_only)?); + Ok(()) + } } diff --git a/src-tauri/src/services/skill_deployment.rs b/src-tauri/src/services/skill_deployment.rs index 7fff183d3..1bb7058b4 100644 --- a/src-tauri/src/services/skill_deployment.rs +++ b/src-tauri/src/services/skill_deployment.rs @@ -158,6 +158,20 @@ impl PiSkillDeploymentService { Self::coordinate_portable_import(|| db.import_portable_sql(source_path)) } + /// Keep legacy binary restore outside the Pi ownership domain until the + /// independent canonical-restore project can preserve local ledgers + /// atomically. The shared Skill boundary prevents a receipt from appearing + /// between the live/source checks and the whole-database replacement. + pub(crate) fn restore_binary_backup_without_pi_ownership( + db: &Database, + filename: &str, + ) -> Result { + Self::coordinate_portable_import(|| { + db.ensure_binary_restore_has_no_pi_ownership(filename)?; + db.restore_from_backup(filename) + }) + } + pub(crate) fn reconcile_skill_under_guard( _guard: &MutexGuard<'static, ()>, db: &Arc, @@ -1181,6 +1195,119 @@ mod tests { worker.join().expect("worker"); } + #[test] + #[serial_test::serial] + fn binary_restore_waits_for_the_skill_ownership_boundary() { + use std::sync::mpsc; + use std::time::Duration; + + let db = Database::memory().expect("database"); + db.claim_pi_projection_key("local-provider", "local-key") + .expect("local ownership"); + let guard = PiSkillDeploymentService::operation_guard(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + ready_tx.send(()).expect("signal worker ready"); + let result = PiSkillDeploymentService::restore_binary_backup_without_pi_ownership( + &db, + "missing.db", + ); + result_tx.send(result).expect("signal restore result"); + }); + + ready_rx + .recv_timeout(Duration::from_secs(2)) + .expect("worker reaches restore entry"); + assert!( + result_rx.recv_timeout(Duration::from_millis(100)).is_err(), + "binary restore must not pass the Pi Skill ownership boundary" + ); + drop(guard); + let error = result_rx + .recv_timeout(Duration::from_secs(2)) + .expect("restore proceeds after boundary release") + .expect_err("live ownership is rejected"); + assert!(error.to_string().contains("portable SQL")); + worker.join().expect("worker"); + } + + #[test] + #[serial_test::serial] + fn binary_restore_service_rejects_ownership_from_the_selected_backup() { + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + let backup_dir = crate::config::get_app_config_dir().join("backups"); + fs::create_dir_all(&backup_dir).expect("backup directory"); + let source = rusqlite::Connection::open(backup_dir.join("owned.db")).expect("owned backup"); + source + .execute_batch( + "CREATE TABLE pi_provider_projections ( + provider_id TEXT PRIMARY KEY, + provider_key TEXT NOT NULL + ); + INSERT INTO pi_provider_projections (provider_id, provider_key) + VALUES ('historical-provider', 'native-key');", + ) + .expect("historical ownership"); + drop(source); + + let db = Database::memory().expect("database"); + let error = + PiSkillDeploymentService::restore_binary_backup_without_pi_ownership(&db, "owned.db") + .expect_err("historical ownership must be rejected before restore"); + match error { + AppError::Localized { en, .. } => { + assert!(en.contains("selected backup")); + assert!(en.contains("portable SQL")); + } + other => panic!("expected structured ownership rejection, got {other:?}"), + } + } + + #[test] + #[serial_test::serial] + fn binary_restore_service_keeps_working_without_pi_ownership() { + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + let db = Database::init().expect("live database"); + let backup = db + .backup_database_file() + .expect("create backup") + .expect("live database exists"); + let filename = backup + .file_name() + .and_then(|value| value.to_str()) + .expect("backup filename"); + + let safety = + PiSkillDeploymentService::restore_binary_backup_without_pi_ownership(&db, filename) + .expect("empty ownership boundary permits legacy binary restore"); + assert!(!safety.is_empty()); + } + #[test] fn digest_includes_hidden_files_and_rejects_symlinks() { let temp = tempfile::tempdir().expect("tempdir");