fix(pi): reject unsafe binary ownership restore

This commit is contained in:
SaladDay
2026-08-03 13:16:52 +00:00
parent 697bcf8e0f
commit f35b47c1cf
3 changed files with 315 additions and 6 deletions
+6 -5
View File
@@ -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) => {
+182 -1
View File
@@ -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(())
}
}
+127
View File
@@ -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");