commit 278349715401e8c8c5d61405dcabffb9880e42e3
parent b096b3695614f3e7fbb2023d17723a48d6048dc7
Author: triesap <tyson@radroots.org>
Date: Sat, 15 Aug 2026 19:08:54 +0000
service-sqlite: close transaction policy escape
Diffstat:
6 files changed, 424 insertions(+), 78 deletions(-)
diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md
@@ -15,6 +15,15 @@ runner-owned. Writable host opening finishes every pending governed migration
before returning, and read-only inspection opens only current migration and
schema state.
+Service-controlled SQL is screened before SQLite compilation through both the
+borrowed transaction executor and migration callback executor. The closed
+statement-control inventory is `PRAGMA`, `ATTACH`, `DETACH`, `BEGIN`, `COMMIT`,
+`END`, `ROLLBACK`, `SAVEPOINT`, and `RELEASE`, regardless of case, whitespace,
+comments, multiple statements, or prepared-query entry point. Rejection is
+sticky for the transaction, so ignoring the immediate SQL error cannot permit
+commit. Runner-owned setup remains private, and the complete connection policy
+is revalidated before commit and before a connection can return to the pool.
+
Cancelling a host transaction before the runner enables outer commit
quarantines its connection and leaves no authoritative transaction effect. A
service-operation error is returned only after rollback is confirmed; an
diff --git a/crates/service_sqlite/src/connection.rs b/crates/service_sqlite/src/connection.rs
@@ -555,14 +555,14 @@ impl ServiceSqliteHost {
}
let operation_result = {
- let database_control_rejected = Arc::new(AtomicBool::new(false));
+ let statement_control_rejected = Arc::new(AtomicBool::new(false));
let mut executor = ServiceSqliteTransaction {
connection: &mut transaction,
- database_control_rejected: Arc::clone(&database_control_rejected),
+ statement_control_rejected: Arc::clone(&statement_control_rejected),
};
- (operation(&mut executor).await, database_control_rejected)
+ (operation(&mut executor).await, statement_control_rejected)
};
- let (operation_result, database_control_rejected) = operation_result;
+ let (operation_result, statement_control_rejected) = operation_result;
if let Err(error) = self.pool.validate() {
let operation_error = operation_result.err();
let permit = gate.permit_runner_rollback();
@@ -618,7 +618,7 @@ impl ServiceSqliteHost {
&mut transaction,
&gate,
&initial_policy,
- &database_control_rejected,
+ &statement_control_rejected,
)
.await;
let precommit = match precommit {
@@ -801,12 +801,12 @@ impl ServiceSqliteHost {
connection: &mut SqliteConnection,
gate: &crate::transaction_control::TransactionControlGate,
initial_policy: &crate::migration::MigrationConnectionPolicy,
- database_control_rejected: &AtomicBool,
+ statement_control_rejected: &AtomicBool,
) -> Result<(), ServiceSqliteError> {
self.pool.validate()?;
crate::require_condition(
!gate.control_violation_observed()
- && !database_control_rejected.load(Ordering::Acquire),
+ && !statement_control_rejected.load(Ordering::Acquire),
ServiceSqliteErrorKind::Open,
)?;
crate::migration::assert_governed_transaction(connection).await?;
@@ -874,12 +874,12 @@ impl fmt::Debug for ServiceSqliteHost {
/// ```
pub struct ServiceSqliteTransaction<'connection> {
connection: &'connection mut SqliteConnection,
- database_control_rejected: Arc<AtomicBool>,
+ statement_control_rejected: Arc<AtomicBool>,
}
struct RestrictedExecute<Q> {
query: Q,
- database_control_rejected: Arc<AtomicBool>,
+ statement_control_rejected: Arc<AtomicBool>,
}
impl<'query, Q> Execute<'query, Sqlite> for RestrictedExecute<Q>
@@ -887,7 +887,7 @@ where
Q: Execute<'query, Sqlite>,
{
fn sql(self) -> SqlStr {
- restricted_sql(self.query.sql(), &self.database_control_rejected)
+ restricted_sql(self.query.sql(), &self.statement_control_rejected)
}
fn statement(&self) -> Option<&SqliteStatement> {
@@ -905,21 +905,15 @@ where
}
}
-fn restricted_sql(sql: SqlStr, database_control_rejected: &AtomicBool) -> SqlStr {
- if contains_database_control(sql.as_str()) {
- database_control_rejected.store(true, Ordering::Release);
- SqlStr::from_static("RADROOTS_FORBIDDEN_DATABASE_CONTROL")
+fn restricted_sql(sql: SqlStr, statement_control_rejected: &AtomicBool) -> SqlStr {
+ if crate::statement_policy::contains_forbidden_statement_control(sql.as_str()) {
+ statement_control_rejected.store(true, Ordering::Release);
+ SqlStr::from_static("RADROOTS_FORBIDDEN_STATEMENT_CONTROL")
} else {
sql
}
}
-pub(crate) fn contains_database_control(sql: &str) -> bool {
- sql.as_bytes()
- .split(|byte| !byte.is_ascii_alphanumeric() && *byte != b'_')
- .any(|token| token.eq_ignore_ascii_case(b"attach") || token.eq_ignore_ascii_case(b"detach"))
-}
-
impl fmt::Debug for ServiceSqliteTransaction<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ServiceSqliteTransaction([redacted])")
@@ -943,7 +937,7 @@ where
{
(&mut *self.connection).fetch_many(RestrictedExecute {
query,
- database_control_rejected: Arc::clone(&self.database_control_rejected),
+ statement_control_rejected: Arc::clone(&self.statement_control_rejected),
})
}
@@ -957,7 +951,7 @@ where
{
(&mut *self.connection).fetch_optional(RestrictedExecute {
query,
- database_control_rejected: Arc::clone(&self.database_control_rejected),
+ statement_control_rejected: Arc::clone(&self.statement_control_rejected),
})
}
@@ -970,7 +964,7 @@ where
'executor: 'e,
{
(&mut *self.connection).prepare_with(
- restricted_sql(sql, &self.database_control_rejected),
+ restricted_sql(sql, &self.statement_control_rejected),
parameters,
)
}
@@ -3279,24 +3273,17 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
- async fn transaction_control_policy_and_attachment_escapes_fail_closed() {
- for (statement, expected_kind) in [
- (
- "INSERT INTO host_probe (value) VALUES (0); COMMIT",
- ServiceSqliteTransactionErrorKind::RollbackFailed,
- ),
- (
- "ROLLBACK; BEGIN DEFERRED; INSERT INTO host_probe (value) VALUES (1)",
- ServiceSqliteTransactionErrorKind::NotCommitted,
- ),
- (
- "PRAGMA trusted_schema=ON; INSERT INTO host_probe (value) VALUES (2)",
- ServiceSqliteTransactionErrorKind::NotCommitted,
- ),
- (
- "ATTACH DATABASE ':memory:' AS extra; INSERT INTO host_probe (value) VALUES (3)",
- ServiceSqliteTransactionErrorKind::NotCommitted,
- ),
+ async fn complete_statement_control_inventory_is_sticky_and_fails_closed() {
+ for statement in [
+ "INSERT INTO host_probe (value) VALUES (0); /* policy */ PrAgMa\ntrusted_schema=ON",
+ "INSERT INTO host_probe (value) VALUES (1); ATTACH DATABASE ':memory:' AS extra",
+ "INSERT INTO host_probe (value) VALUES (2); DETACH DATABASE extra",
+ "INSERT INTO host_probe (value) VALUES (3); BEGIN DEFERRED",
+ "INSERT INTO host_probe (value) VALUES (4); COMMIT",
+ "INSERT INTO host_probe (value) VALUES (5); END",
+ "INSERT INTO host_probe (value) VALUES (6); ROLLBACK",
+ "INSERT INTO host_probe (value) VALUES (7); SAVEPOINT escaped",
+ "INSERT INTO host_probe (value) VALUES (8); RELEASE SAVEPOINT escaped",
] {
let (_root, _paths, _identity, _migrations, _schema, host) = initialized_host().await;
let error = host
@@ -3308,7 +3295,10 @@ mod tests {
})
.await
.expect_err("escape attempt must not commit");
- assert_eq!(error.kind(), expected_kind);
+ assert_eq!(
+ error.kind(),
+ ServiceSqliteTransactionErrorKind::NotCommitted
+ );
assert!(error.sqlite_error().is_some());
assert_eq!(row_count(&host).await, 0);
}
@@ -3338,6 +3328,58 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
+ async fn prepared_query_policy_rejection_is_sticky_and_rolls_back_prior_work() {
+ let (_root, _paths, _identity, _migrations, _schema, host) = initialized_host().await;
+ let error = host
+ .transaction(|transaction| {
+ Box::pin(async move {
+ sqlx::query("INSERT INTO host_probe (value) VALUES (11)")
+ .execute(&mut *transaction)
+ .await
+ .expect("ordinary service statement");
+ let _ = (&mut *transaction)
+ .prepare(SqlStr::from_static(
+ "SELECT 1; /* ignored */ PRAGMA trusted_schema=ON",
+ ))
+ .await;
+ Ok::<_, Infallible>(())
+ })
+ })
+ .await
+ .expect_err("ignored prepared-query rejection must block commit");
+ assert_eq!(
+ error.kind(),
+ ServiceSqliteTransactionErrorKind::NotCommitted
+ );
+ assert_eq!(row_count(&host).await, 0);
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test]
+ async fn control_words_in_values_and_case_expressions_remain_available() {
+ let (_root, _paths, _identity, _migrations, _schema, host) = initialized_host().await;
+ let value = host
+ .transaction(|transaction| {
+ Box::pin(async move {
+ let value = sqlx::query_scalar::<_, String>(
+ "SELECT CASE WHEN 1 = 1 THEN 'commit' ELSE 'end' END",
+ )
+ .fetch_one(&mut *transaction)
+ .await?;
+ sqlx::query("INSERT INTO host_probe (value) VALUES (12)")
+ .execute(&mut *transaction)
+ .await?;
+ Ok::<_, sqlx::Error>(value)
+ })
+ })
+ .await
+ .expect("ordinary expression commits");
+ assert_eq!(value, "commit");
+ assert_eq!(row_count(&host).await, 1);
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test]
async fn attach_detach_is_rejected_before_it_can_create_external_state() {
let (root, _paths, _identity, _migrations, _schema, host) = initialized_host().await;
let external_database = root.path().join("forbidden-attachment.sqlite");
@@ -3481,25 +3523,6 @@ mod tests {
);
}
- #[test]
- fn database_control_token_screen_is_closed_and_case_insensitive() {
- for forbidden in [
- "ATTACH DATABASE 'x' AS extra",
- "detach database extra",
- "SELECT 1; /* ignored */ AtTaCh ':memory:' AS x",
- "SELECT 'attach'",
- ] {
- assert!(contains_database_control(forbidden));
- }
- for allowed in [
- "SELECT attachment FROM items",
- "SELECT detached FROM items",
- "SELECT COUNT(*) FROM host_probe",
- ] {
- assert!(!contains_database_control(allowed));
- }
- }
-
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
async fn read_only_writes_roll_back_and_internal_pool_close_refuses_new_work() {
diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs
@@ -24,6 +24,7 @@ mod restore;
reason = "the sealed SQLx-handle adapter owns the missing SQLite online-backup calls"
)]
mod sqlite_native_backup;
+mod statement_policy;
mod status;
mod transaction_control;
diff --git a/crates/service_sqlite/src/migration.rs b/crates/service_sqlite/src/migration.rs
@@ -630,13 +630,13 @@ pub type MigrationCallback =
pub struct MigrationTransactionExecutor<'a> {
connection: &'a mut SqliteConnection,
- database_control_rejected: bool,
+ statement_control_rejected: bool,
}
impl MigrationTransactionExecutor<'_> {
pub async fn execute(&mut self, sql: &'static str) -> Result<(), ServiceSqliteError> {
- if crate::connection::contains_database_control(sql) {
- self.database_control_rejected = true;
+ if crate::statement_policy::contains_forbidden_statement_control(sql) {
+ self.statement_control_rejected = true;
return Err(ServiceSqliteError::new(
crate::ServiceSqliteErrorKind::Migration,
));
@@ -1084,7 +1084,7 @@ async fn execute_descriptor(
) -> Result<(), ServiceSqliteError> {
let mut executor = MigrationTransactionExecutor {
connection,
- database_control_rejected: false,
+ statement_control_rejected: false,
};
match descriptor.kind() {
MigrationKind::Sql => {
@@ -1102,7 +1102,7 @@ async fn execute_descriptor(
assert_governed_transaction(executor.connection).await?;
}
}
- if executor.database_control_rejected {
+ if executor.statement_control_rejected {
return Err(migration_error(MigrationFailureKind::Execution));
}
Ok(())
@@ -1738,13 +1738,13 @@ mod tests {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
- fn ignored_attachment_callback<'a>(
+ fn ignored_statement_control_callback<'a>(
executor: &'a mut MigrationTransactionExecutor<'_>,
) -> MigrationCallbackFuture<'a> {
- let sql = IGNORED_ATTACHMENT_SQL
+ let sql = IGNORED_STATEMENT_CONTROL_SQL
.lock()
- .expect("attachment SQL mutex")
- .expect("attachment SQL is installed");
+ .expect("statement-control SQL mutex")
+ .expect("statement-control SQL is installed");
Box::pin(async move {
let _ = executor.execute(sql).await;
Ok(())
@@ -1755,7 +1755,7 @@ mod tests {
static PENDING_CALLBACK_COUNT: AtomicUsize = AtomicUsize::new(0);
#[cfg(any(target_os = "linux", target_os = "macos"))]
- static IGNORED_ATTACHMENT_SQL: Mutex<Option<&'static str>> = Mutex::new(None);
+ static IGNORED_STATEMENT_CONTROL_SQL: Mutex<Option<&'static str>> = Mutex::new(None);
#[cfg(any(target_os = "linux", target_os = "macos"))]
async fn replace_with_permissive_ledger(connection: &mut SqliteConnection) {
@@ -2387,6 +2387,81 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test(flavor = "current_thread")]
+ async fn migration_sql_rejects_complete_statement_control_inventory_before_execution() {
+ for (name, statement) in [
+ (
+ "reject_pragma",
+ "CREATE TABLE leaked (id INTEGER); /* policy */ PrAgMa\ntrusted_schema=ON",
+ ),
+ (
+ "reject_attach",
+ "CREATE TABLE leaked (id INTEGER); ATTACH ':memory:' AS escaped",
+ ),
+ (
+ "reject_detach",
+ "CREATE TABLE leaked (id INTEGER); DETACH DATABASE escaped",
+ ),
+ (
+ "reject_begin",
+ "CREATE TABLE leaked (id INTEGER); BEGIN DEFERRED",
+ ),
+ ("reject_commit", "CREATE TABLE leaked (id INTEGER); COMMIT"),
+ (
+ "reject_end",
+ "CREATE TABLE leaked (id INTEGER); END TRANSACTION",
+ ),
+ (
+ "reject_rollback",
+ "CREATE TABLE leaked (id INTEGER); ROLLBACK",
+ ),
+ (
+ "reject_savepoint",
+ "CREATE TABLE leaked (id INTEGER); SAVEPOINT escaped",
+ ),
+ (
+ "reject_release",
+ "CREATE TABLE leaked (id INTEGER); RELEASE SAVEPOINT escaped",
+ ),
+ ] {
+ let directory = tempfile::tempdir().unwrap();
+ let database_path = directory.path().join("statement-control.sqlite");
+ let mut connection = initialized_file_database(&database_path).await;
+ let catalog = MigrationCatalog::new([sql(2, name, statement)]).unwrap();
+ let schema_catalog = unchanged_schema_catalog(&catalog);
+ let mut validate = || Ok(());
+ let error = apply_governed_migrations(
+ &mut connection,
+ &catalog,
+ &schema_catalog,
+ MigrationAppliedAtUnixSeconds::new(1_800_000_000).unwrap(),
+ &build_identity(),
+ &[],
+ &mut validate,
+ )
+ .await
+ .expect_err("statement-control migration must be rejected");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Migration);
+ assert_eq!(read_state_schema_version(&mut connection).await.unwrap(), 1);
+ assert!(
+ read_migration_history(&mut connection)
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ assert_eq!(
+ sqlx::query_scalar::<_, i64>(
+ "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'leaked'",
+ )
+ .fetch_one(&mut connection)
+ .await
+ .unwrap(),
+ 0
+ );
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "current_thread")]
async fn migration_executor_rejects_transient_attachment_before_file_creation() {
let directory = tempfile::tempdir().unwrap();
let database_path = directory.path().join("main.sqlite");
@@ -2434,7 +2509,9 @@ mod tests {
)
.into_boxed_str(),
);
- *IGNORED_ATTACHMENT_SQL.lock().expect("attachment SQL mutex") = Some(callback_sql);
+ *IGNORED_STATEMENT_CONTROL_SQL
+ .lock()
+ .expect("statement-control SQL mutex") = Some(callback_sql);
let mut callback_connection = initialized_file_database(&callback_database_path).await;
let callback_descriptor = MigrationDescriptor::callback(
2,
@@ -2447,7 +2524,7 @@ mod tests {
callback_descriptor.target_version(),
callback_descriptor.name(),
callback_descriptor.checksum(),
- ignored_attachment_callback,
+ ignored_statement_control_callback,
);
let callback_catalog = MigrationCatalog::new([callback_descriptor]).unwrap();
let callback_schema_catalog = unchanged_schema_catalog(&callback_catalog);
@@ -2462,7 +2539,9 @@ mod tests {
)
.await
.expect_err("ignored callback ATTACH/DETACH must still fail the migration");
- *IGNORED_ATTACHMENT_SQL.lock().expect("attachment SQL mutex") = None;
+ *IGNORED_STATEMENT_CONTROL_SQL
+ .lock()
+ .expect("statement-control SQL mutex") = None;
assert_eq!(callback_error.kind(), ServiceSqliteErrorKind::Migration);
assert!(!callback_external_path.exists());
assert_eq!(
diff --git a/crates/service_sqlite/src/statement_policy.rs b/crates/service_sqlite/src/statement_policy.rs
@@ -0,0 +1,207 @@
+//! Private service-controlled SQL admission policy.
+
+#[derive(Clone, Copy)]
+enum CreatePrefix {
+ None,
+ Create,
+ CreateTemp,
+ Other,
+}
+
+pub(crate) fn contains_forbidden_statement_control(sql: &str) -> bool {
+ let bytes = sql.as_bytes();
+ let mut cursor = 0;
+ let mut statement_start = true;
+ let mut create_prefix = CreatePrefix::None;
+ let mut trigger_definition = false;
+ let mut trigger_body = false;
+ let mut trigger_case_depth = 0_u32;
+ let mut trigger_end_seen = false;
+
+ while cursor < bytes.len() {
+ let byte = bytes[cursor];
+ if statement_start && bytes[cursor..].starts_with(&[0xef, 0xbb, 0xbf]) {
+ cursor += 3;
+ continue;
+ }
+ if byte.is_ascii_whitespace() {
+ cursor += 1;
+ continue;
+ }
+ if byte == b'-' && bytes.get(cursor + 1) == Some(&b'-') {
+ cursor += 2;
+ while cursor < bytes.len() && !matches!(bytes[cursor], b'\n' | b'\r') {
+ cursor += 1;
+ }
+ continue;
+ }
+ if byte == b'/' && bytes.get(cursor + 1) == Some(&b'*') {
+ cursor += 2;
+ while cursor + 1 < bytes.len() && !(bytes[cursor] == b'*' && bytes[cursor + 1] == b'/')
+ {
+ cursor += 1;
+ }
+ cursor = bytes.len().min(cursor + 2);
+ continue;
+ }
+ if matches!(byte, b'\'' | b'"' | b'`' | b'[') {
+ let terminator = if byte == b'[' { b']' } else { byte };
+ statement_start = false;
+ create_prefix = CreatePrefix::Other;
+ cursor += 1;
+ while cursor < bytes.len() {
+ if bytes[cursor] == terminator {
+ if bytes.get(cursor + 1) == Some(&terminator) {
+ cursor += 2;
+ } else {
+ cursor += 1;
+ break;
+ }
+ } else {
+ cursor += 1;
+ }
+ }
+ continue;
+ }
+ if byte == b';' {
+ if trigger_definition && trigger_body && !trigger_end_seen {
+ cursor += 1;
+ continue;
+ }
+ statement_start = true;
+ create_prefix = CreatePrefix::None;
+ trigger_definition = false;
+ trigger_body = false;
+ trigger_case_depth = 0;
+ trigger_end_seen = false;
+ cursor += 1;
+ continue;
+ }
+ if !is_identifier(byte) {
+ statement_start = false;
+ create_prefix = CreatePrefix::Other;
+ cursor += 1;
+ continue;
+ }
+
+ let start = cursor;
+ cursor += 1;
+ while cursor < bytes.len() && is_identifier(bytes[cursor]) {
+ cursor += 1;
+ }
+ let token = &bytes[start..cursor];
+
+ if trigger_definition {
+ if !trigger_body && token.eq_ignore_ascii_case(b"begin") {
+ trigger_body = true;
+ } else if trigger_body && token.eq_ignore_ascii_case(b"case") {
+ trigger_case_depth = trigger_case_depth.saturating_add(1);
+ } else if trigger_body && token.eq_ignore_ascii_case(b"end") {
+ if trigger_case_depth == 0 {
+ trigger_end_seen = true;
+ } else {
+ trigger_case_depth -= 1;
+ }
+ }
+ continue;
+ }
+
+ if statement_start {
+ if is_forbidden(token) {
+ return true;
+ }
+ statement_start = false;
+ create_prefix = if token.eq_ignore_ascii_case(b"create") {
+ CreatePrefix::Create
+ } else {
+ CreatePrefix::Other
+ };
+ continue;
+ }
+
+ create_prefix = match create_prefix {
+ CreatePrefix::Create
+ if token.eq_ignore_ascii_case(b"temp")
+ || token.eq_ignore_ascii_case(b"temporary") =>
+ {
+ CreatePrefix::CreateTemp
+ }
+ CreatePrefix::Create | CreatePrefix::CreateTemp
+ if token.eq_ignore_ascii_case(b"trigger") =>
+ {
+ trigger_definition = true;
+ CreatePrefix::None
+ }
+ CreatePrefix::Create | CreatePrefix::CreateTemp => CreatePrefix::Other,
+ other => other,
+ };
+ }
+
+ false
+}
+
+fn is_identifier(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric() || byte == b'_'
+}
+
+fn is_forbidden(token: &[u8]) -> bool {
+ [
+ b"pragma".as_slice(),
+ b"attach",
+ b"detach",
+ b"begin",
+ b"commit",
+ b"end",
+ b"rollback",
+ b"savepoint",
+ b"release",
+ ]
+ .into_iter()
+ .any(|forbidden| token.eq_ignore_ascii_case(forbidden))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn statement_control_inventory_is_closed_and_case_insensitive() {
+ for forbidden in [
+ "/* ignored before control */ PRAGMA trusted_schema = ON",
+ "\u{feff}PRAGMA trusted_schema = ON",
+ "ATTACH DATABASE 'x' AS extra",
+ "detach database extra",
+ " /* ignored */ BeGiN IMMEDIATE",
+ "SELECT 1; -- ignored\n CoMmIt",
+ "END TRANSACTION",
+ "ROLLBACK TO escaped",
+ "SAVEPOINT escaped",
+ "RELEASE SAVEPOINT escaped",
+ "CREATE TRIGGER audit_insert AFTER INSERT ON items BEGIN INSERT INTO audit_log (value) VALUES (NEW.value); END; /* after trigger */ COMMIT",
+ ] {
+ assert!(contains_forbidden_statement_control(forbidden));
+ }
+ }
+
+ #[test]
+ fn values_identifiers_comments_case_and_triggers_remain_available() {
+ for allowed in [
+ "SELECT 'pragma attach detach begin commit end rollback savepoint release'",
+ "SELECT CASE WHEN value = 1 THEN 'commit' ELSE 'end' END FROM items",
+ "SELECT 1 /* PRAGMA ATTACH COMMIT */",
+ "CREATE TRIGGER audit_insert AFTER INSERT ON items BEGIN INSERT INTO audit_log (value) VALUES (CASE WHEN NEW.value = 1 THEN 'commit' ELSE 'end' END); END;",
+ "SELECT pragmatic FROM items",
+ "SELECT attachment FROM items",
+ "SELECT detached FROM items",
+ "SELECT beginner FROM items",
+ "SELECT committed FROM items",
+ "SELECT ending FROM items",
+ "SELECT rolled_back FROM items",
+ "SELECT savepoints FROM items",
+ "SELECT released FROM items",
+ "SELECT COUNT(*) FROM host_probe",
+ ] {
+ assert!(!contains_forbidden_statement_control(allowed));
+ }
+ }
+}
diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs
@@ -33,6 +33,7 @@ const RESTORE_STAGE_SOURCE: &str = include_str!("../src/restore/stage.rs");
const SQLITE_NATIVE_BACKUP_SOURCE: &str = include_str!("../src/sqlite_native_backup.rs");
const STATUS_SOURCE: &str = include_str!("../src/status/mod.rs");
const DISK_SOURCE: &str = include_str!("../src/status/disk.rs");
+const STATEMENT_POLICY_SOURCE: &str = include_str!("../src/statement_policy.rs");
const TRANSACTION_CONTROL_SOURCE: &str = include_str!("../src/transaction_control.rs");
const WRITER_PROCESS_TEST_SOURCE: &str = include_str!("writer_authority.rs");
@@ -129,6 +130,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"restore",
"sqlite_native_backup",
"status",
+ "statement_policy",
"transaction_control"
])
);
@@ -138,6 +140,11 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"borrowed `ServiceSqliteTransaction` executor",
"transaction begin, commit, rollback, policy",
"attached-database exclusion",
+ "Service-controlled SQL is screened before SQLite compilation",
+ "statement-control inventory is `PRAGMA`, `ATTACH`, `DETACH`, `BEGIN`, `COMMIT`,",
+ "`END`, `ROLLBACK`, `SAVEPOINT`, and `RELEASE`",
+ "sticky for the transaction",
+ "is revalidated before commit and before a connection can return to the pool",
"Writable host opening finishes every pending governed migration",
"read-only inspection opens only current migration and",
"with raw database authority",
@@ -786,8 +793,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"validate_callback_bindings",
"advance_schema_version",
"pub struct MigrationTransactionExecutor",
- "contains_database_control",
- "database_control_rejected",
+ "contains_forbidden_statement_control",
+ "statement_control_rejected",
"SAVEPOINT radroots_migration_transaction_probe",
"FROM pragma_database_list",
"CASE WHEN typeof(name) = 'text'",
@@ -812,8 +819,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"connection.close_on_drop()",
"connection.trust()",
"RestrictedExecute",
- "contains_database_control",
- "RADROOTS_FORBIDDEN_DATABASE_CONTROL",
+ "contains_forbidden_statement_control",
+ "RADROOTS_FORBIDDEN_STATEMENT_CONTROL",
"OperationRolledBack",
"RollbackFailed",
"CommitOutcomeUnknown",
@@ -833,6 +840,26 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
}
for required in [
+ "pub(crate) fn contains_forbidden_statement_control",
+ "b\"pragma\".as_slice()",
+ "b\"attach\"",
+ "b\"detach\"",
+ "b\"begin\"",
+ "b\"commit\"",
+ "b\"end\"",
+ "b\"rollback\"",
+ "b\"savepoint\"",
+ "b\"release\"",
+ "trigger_definition",
+ "trigger_case_depth",
+ ] {
+ assert!(
+ STATEMENT_POLICY_SOURCE.contains(required),
+ "statement-policy source is missing `{required}`"
+ );
+ }
+
+ for required in [
"set_commit_hook",
"set_rollback_hook",
"permit_outer_commit",