mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
fix(pi): close final gateway ownership regressions
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
use crate::database::SkillDeploymentMethod;
|
||||
use crate::database::{lock_conn, Database, PiProviderProjection, SkillDeployment};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
|
||||
use rusqlite::{Connection, OpenFlags, OptionalExtension};
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -47,9 +48,9 @@ impl Database {
|
||||
/// Import a user-portable SQL backup while retaining this device's evidence.
|
||||
pub(crate) fn import_portable_sql(&self, source_path: &Path) -> Result<String, AppError> {
|
||||
let local = self.capture_pi_device_local_state()?;
|
||||
let backup_id = self.import_sql(source_path)?;
|
||||
self.replace_pi_device_local_state(&local)?;
|
||||
Ok(backup_id)
|
||||
let sql =
|
||||
fs::read_to_string(source_path).map_err(|error| AppError::io(source_path, error))?;
|
||||
self.import_sql_string(&append_pi_device_local_state(&sql, &local)?)
|
||||
}
|
||||
|
||||
/// Import a cloud-sync snapshot while retaining this device's evidence.
|
||||
@@ -58,9 +59,7 @@ impl Database {
|
||||
sql: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let local = self.capture_pi_device_local_state()?;
|
||||
let backup_id = self.import_sql_string_for_sync(sql)?;
|
||||
self.replace_pi_device_local_state(&local)?;
|
||||
Ok(backup_id)
|
||||
self.import_sql_string_for_sync(&append_pi_device_local_state(sql, &local)?)
|
||||
}
|
||||
|
||||
/// Fail closed before the legacy whole-database restore can import
|
||||
@@ -181,64 +180,13 @@ impl Database {
|
||||
skill_deployments,
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_pi_device_local_state(&self, local: &PiDeviceLocalState) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.execute("DELETE FROM pi_provider_projections", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.execute("DELETE FROM skill_deployments WHERE app_type = 'pi'", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
|
||||
for projection in &local.projections {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
projection.provider_id,
|
||||
projection.provider_key,
|
||||
projection.created_at,
|
||||
projection.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
for deployment in &local.skill_deployments {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO skill_deployments (
|
||||
app_type, skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
deployment.skill_id,
|
||||
deployment.destination,
|
||||
deployment.destination_key,
|
||||
deployment.method.as_str(),
|
||||
deployment.source_identity,
|
||||
deployment.deployed_digest,
|
||||
deployment.created_at,
|
||||
deployment.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn pi_device_local_rows_exist(conn: &Connection) -> Result<bool, AppError> {
|
||||
let table_exists = |name: &str| -> Result<bool, AppError> {
|
||||
let schema_object_exists = |name: &str| -> Result<bool, AppError> {
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
"SELECT 1 FROM sqlite_master
|
||||
WHERE name = ?1 COLLATE NOCASE",
|
||||
[name],
|
||||
|_| Ok(()),
|
||||
)
|
||||
@@ -247,7 +195,7 @@ fn pi_device_local_rows_exist(conn: &Connection) -> Result<bool, AppError> {
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
};
|
||||
|
||||
let projections_exist = if table_exists("pi_provider_projections")? {
|
||||
let projections_exist = if schema_object_exists("pi_provider_projections")? {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pi_provider_projections LIMIT 1)",
|
||||
[],
|
||||
@@ -261,7 +209,7 @@ fn pi_device_local_rows_exist(conn: &Connection) -> Result<bool, AppError> {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if table_exists("skill_deployments")? {
|
||||
if schema_object_exists("skill_deployments")? {
|
||||
return conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(
|
||||
@@ -277,6 +225,165 @@ fn pi_device_local_rows_exist(conn: &Connection) -> Result<bool, AppError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Merge receiving-device evidence into the same temporary database that the
|
||||
/// generic importer validates and publishes. No live database replacement can
|
||||
/// occur before these statements have succeeded.
|
||||
fn append_pi_device_local_state(sql: &str, local: &PiDeviceLocalState) -> Result<String, AppError> {
|
||||
ensure_sql_append_boundary(sql)?;
|
||||
let mut merged = String::with_capacity(sql.len() + 1024);
|
||||
merged.push_str(sql);
|
||||
// The leading newline closes a trailing `--` comment; the standalone
|
||||
// semicolon terminates a final statement that omitted its delimiter.
|
||||
merged.push_str(
|
||||
"\n;\n\
|
||||
DROP TABLE IF EXISTS pi_provider_projections;\n\
|
||||
CREATE TABLE pi_provider_projections (\n\
|
||||
provider_id TEXT PRIMARY KEY,\n\
|
||||
provider_key TEXT NOT NULL UNIQUE,\n\
|
||||
created_at INTEGER NOT NULL,\n\
|
||||
updated_at INTEGER NOT NULL\n\
|
||||
);\n\
|
||||
DROP TABLE IF EXISTS skill_deployments;\n\
|
||||
CREATE TABLE skill_deployments (\n\
|
||||
app_type TEXT NOT NULL CHECK (app_type = 'pi'),\n\
|
||||
skill_id TEXT NOT NULL,\n\
|
||||
destination TEXT NOT NULL,\n\
|
||||
destination_key TEXT NOT NULL,\n\
|
||||
method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')),\n\
|
||||
source_identity TEXT NOT NULL,\n\
|
||||
deployed_digest TEXT,\n\
|
||||
created_at INTEGER NOT NULL,\n\
|
||||
updated_at INTEGER NOT NULL,\n\
|
||||
PRIMARY KEY (app_type, skill_id, destination_key),\n\
|
||||
UNIQUE (app_type, destination_key)\n\
|
||||
);\n",
|
||||
);
|
||||
|
||||
for projection in &local.projections {
|
||||
writeln!(
|
||||
merged,
|
||||
"INSERT INTO pi_provider_projections \
|
||||
(provider_id, provider_key, created_at, updated_at) \
|
||||
VALUES ({}, {}, {}, {});",
|
||||
sql_text(&projection.provider_id)?,
|
||||
sql_text(&projection.provider_key)?,
|
||||
projection.created_at,
|
||||
projection.updated_at,
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
for deployment in &local.skill_deployments {
|
||||
writeln!(
|
||||
merged,
|
||||
"INSERT INTO skill_deployments \
|
||||
(app_type, skill_id, destination, destination_key, method, \
|
||||
source_identity, deployed_digest, created_at, updated_at) \
|
||||
VALUES ('pi', {}, {}, {}, {}, {}, {}, {}, {});",
|
||||
sql_text(&deployment.skill_id)?,
|
||||
sql_text(&deployment.destination)?,
|
||||
sql_text(&deployment.destination_key)?,
|
||||
sql_text(deployment.method.as_str())?,
|
||||
sql_text(&deployment.source_identity)?,
|
||||
sql_optional_text(deployment.deployed_digest.as_deref())?,
|
||||
deployment.created_at,
|
||||
deployment.updated_at,
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn sql_optional_text(value: Option<&str>) -> Result<String, AppError> {
|
||||
value.map_or_else(|| Ok("NULL".to_string()), sql_text)
|
||||
}
|
||||
|
||||
fn sql_text(value: &str) -> Result<String, AppError> {
|
||||
if value.contains('\0') {
|
||||
return Err(AppError::InvalidInput(
|
||||
"device-local Pi ownership text cannot contain NUL".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(format!("'{}'", value.replace('\'', "''")))
|
||||
}
|
||||
|
||||
/// SQLite accepts an unterminated block comment at EOF. Reject such input so
|
||||
/// it cannot swallow the receiving-device statements appended above. Other
|
||||
/// unterminated quoted forms are rejected here as a clearer pre-publish error.
|
||||
fn ensure_sql_append_boundary(sql: &str) -> Result<(), AppError> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
Normal,
|
||||
SingleQuote,
|
||||
DoubleQuote,
|
||||
Backtick,
|
||||
Bracket,
|
||||
LineComment,
|
||||
BlockComment,
|
||||
}
|
||||
|
||||
let bytes = sql.as_bytes();
|
||||
let mut state = State::Normal;
|
||||
let mut cursor = 0;
|
||||
while cursor < bytes.len() {
|
||||
let current = bytes[cursor];
|
||||
let next = bytes.get(cursor + 1).copied();
|
||||
match state {
|
||||
State::Normal => match (current, next) {
|
||||
(b'\'', _) => state = State::SingleQuote,
|
||||
(b'"', _) => state = State::DoubleQuote,
|
||||
(b'`', _) => state = State::Backtick,
|
||||
(b'[', _) => state = State::Bracket,
|
||||
(b'-', Some(b'-')) => {
|
||||
state = State::LineComment;
|
||||
cursor += 1;
|
||||
}
|
||||
(b'/', Some(b'*')) => {
|
||||
state = State::BlockComment;
|
||||
cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
State::SingleQuote if current == b'\'' => {
|
||||
if next == Some(b'\'') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::DoubleQuote if current == b'"' => {
|
||||
if next == Some(b'"') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::Backtick if current == b'`' => {
|
||||
if next == Some(b'`') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::Bracket if current == b']' => state = State::Normal,
|
||||
State::LineComment if current == b'\n' => state = State::Normal,
|
||||
State::BlockComment if current == b'*' && next == Some(b'/') => {
|
||||
state = State::Normal;
|
||||
cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if matches!(state, State::Normal | State::LineComment) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::InvalidInput(
|
||||
"portable SQL ends inside an unterminated quoted value or comment".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_restore_ownership_error(source_zh: &str, source_en: &str) -> AppError {
|
||||
AppError::localized(
|
||||
"pi.binary_restore_device_ownership_unsupported",
|
||||
@@ -369,6 +476,20 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_restore_poison_trigger(sql: &str) -> String {
|
||||
let insertion = sql
|
||||
.rfind("COMMIT;")
|
||||
.expect("CC Switch dump must contain a final COMMIT");
|
||||
let mut poisoned = sql.to_string();
|
||||
poisoned.insert_str(
|
||||
insertion,
|
||||
"CREATE TRIGGER poison_pi_restore \
|
||||
BEFORE INSERT ON pi_provider_projections \
|
||||
BEGIN SELECT RAISE(ABORT, 'poisoned local restore'); END;\n",
|
||||
);
|
||||
poisoned
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_sql_scrubber_handles_multiline_quoted_values() -> Result<(), AppError> {
|
||||
let sql = concat!(
|
||||
@@ -414,7 +535,7 @@ mod tests {
|
||||
remote.claim_pi_projection_key("remote-provider", "shared-key")?;
|
||||
remote.save_pi_skill_deployment(&deployment("remote-skill", "remote-destination"))?;
|
||||
// Model an older remote snapshot created before portable row scrubbing.
|
||||
let remote_sql = remote.export_sql_string()?;
|
||||
let remote_sql = add_restore_poison_trigger(&remote.export_sql_string()?);
|
||||
|
||||
let local = Database::memory()?;
|
||||
local.claim_pi_projection_key("local-provider", "shared-key")?;
|
||||
@@ -443,7 +564,11 @@ mod tests {
|
||||
remote.claim_pi_projection_key("remote-provider", "remote-key")?;
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("portable.sql");
|
||||
fs::write(&path, remote.export_sql_string()?).expect("write SQL backup");
|
||||
fs::write(
|
||||
&path,
|
||||
add_restore_poison_trigger(&remote.export_sql_string()?),
|
||||
)
|
||||
.expect("write SQL backup");
|
||||
|
||||
let local = Database::memory()?;
|
||||
local.claim_pi_projection_key("local-provider", "local-key")?;
|
||||
@@ -482,11 +607,11 @@ mod tests {
|
||||
let conn =
|
||||
Connection::open(path).map_err(|error| AppError::Database(error.to_string()))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE pi_provider_projections (
|
||||
"CREATE TABLE PI_PROVIDER_PROJECTIONS (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
provider_key TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE skill_deployments (
|
||||
CREATE TABLE SKILL_DEPLOYMENTS (
|
||||
app_type TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
@@ -531,6 +656,47 @@ mod tests {
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert!(pi_device_local_rows_exist(&skill_only)?);
|
||||
|
||||
let view_backed =
|
||||
Connection::open_in_memory().map_err(|error| AppError::Database(error.to_string()))?;
|
||||
view_backed
|
||||
.execute_batch(
|
||||
"CREATE VIEW PI_PROVIDER_PROJECTIONS AS
|
||||
SELECT 'view-provider' AS provider_id, 'view-key' AS provider_key;",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert!(
|
||||
pi_device_local_rows_exist(&view_backed)?,
|
||||
"a reserved-name view must not bypass binary ownership rejection"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_import_rejects_an_unclosed_comment_before_live_replacement() -> Result<(), AppError>
|
||||
{
|
||||
let remote = Database::memory()?;
|
||||
seed_portable_provider(&remote)?;
|
||||
let malicious = format!("{}/*", remote.export_sql_string()?);
|
||||
|
||||
let local = Database::memory()?;
|
||||
local.claim_pi_projection_key("local-provider", "local-key")?;
|
||||
let error = local
|
||||
.import_portable_sql_string_for_sync(&malicious)
|
||||
.expect_err("the appended ownership program must not be swallowed");
|
||||
assert!(error.to_string().contains("unterminated"));
|
||||
assert_eq!(
|
||||
local
|
||||
.get_pi_projection("local-provider")?
|
||||
.map(|projection| projection.provider_key),
|
||||
Some("local-key".to_string())
|
||||
);
|
||||
assert!(
|
||||
local
|
||||
.get_provider_by_id("portable-sentinel", "codex")?
|
||||
.is_none(),
|
||||
"the remote database must not be published"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user