mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
fix(pi): reject unsafe binary ownership restore
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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<PiDeviceLocalState, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -183,6 +235,60 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
fn pi_device_local_rows_exist(conn: &Connection) -> Result<bool, AppError> {
|
||||
let table_exists = |name: &str| -> Result<bool, AppError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
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<Database>,
|
||||
@@ -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<std::ffi::OsString>);
|
||||
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<std::ffi::OsString>);
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user