fix(pi): close final gateway ownership regressions

This commit is contained in:
SaladDay
2026-08-03 14:22:17 +00:00
parent f35b47c1cf
commit 63b1a20eef
12 changed files with 832 additions and 215 deletions
+233 -67
View File
@@ -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(())
}
}