use std::sync::Arc;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq)]
pub enum SqlValue {
Null,
Boolean(bool),
Integer(i64),
Real(f64),
Text(String),
Blob(Vec<u8>),
Json(String),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SqlRows {
pub columns: Vec<String>,
pub rows: Vec<Vec<SqlValue>>,
}
impl SqlRows {
pub fn to_json_string(&self) -> String {
use serde_json::{Map, Number, Value};
let cell = |v: &SqlValue| -> Value {
match v {
SqlValue::Null => Value::Null,
SqlValue::Boolean(b) => Value::Bool(*b),
SqlValue::Integer(n) => Value::Number((*n).into()),
SqlValue::Real(f) => Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
SqlValue::Text(s) => Value::String(s.clone()),
SqlValue::Json(s) => {
serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.clone()))
}
SqlValue::Blob(b) => Value::String(String::from_utf8_lossy(b).into_owned()),
}
};
let rows: Vec<Value> = self
.rows
.iter()
.map(|r| Value::Array(r.iter().map(cell).collect()))
.collect();
let mut obj = Map::new();
obj.insert(
"columns".into(),
Value::Array(
self.columns
.iter()
.map(|c| Value::String(c.clone()))
.collect(),
),
);
obj.insert("rows".into(), Value::Array(rows));
Value::Object(obj).to_string()
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum SqlError {
#[error("sql syntax error: {0}")]
Syntax(String),
#[error("sql constraint error: {0}")]
Constraint(String),
#[error("sql error: {0}")]
Other(String),
#[error("sql backend not ready: {0}")]
Unavailable(String),
}
impl SqlError {
pub fn other<E: std::fmt::Display>(err: E) -> Self {
Self::Other(err.to_string())
}
pub fn unavailable<E: std::fmt::Display>(err: E) -> Self {
Self::Unavailable(err.to_string())
}
pub fn is_unavailable(&self) -> bool {
matches!(self, Self::Unavailable(_))
}
}
#[derive(Debug, Clone)]
pub struct RlsGuc {
pub tenant: String,
pub session: Option<String>,
pub all_marker: Option<String>,
}
impl RlsGuc {
pub fn reserved_names(&self) -> Vec<String> {
let mut v = vec![self.tenant.to_ascii_lowercase()];
if let Some(s) = &self.session {
v.push(s.to_ascii_lowercase());
}
v
}
}
pub fn render_set_local_guc(name: &str, value: &SqlValue) -> (String, Vec<SqlValue>) {
let text = match value {
SqlValue::Text(s) => s.clone(),
SqlValue::Integer(i) => i.to_string(),
SqlValue::Boolean(b) => b.to_string(),
SqlValue::Real(r) => r.to_string(),
SqlValue::Blob(_) | SqlValue::Null | SqlValue::Json(_) => String::new(),
};
(
"SELECT set_config(?1, ?2, true)".to_string(),
vec![SqlValue::Text(name.to_string()), SqlValue::Text(text)],
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Dialect {
#[default]
Sqlite,
Postgres,
Mysql,
}
#[async_trait]
pub trait SqlBackend: Send + Sync {
fn dialect(&self) -> Dialect {
Dialect::Sqlite
}
fn rls_guc(&self) -> Option<&RlsGuc> {
None
}
async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError>;
async fn begin_read_only(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
self.begin().await
}
async fn run_script(&self, _sql: &str) -> Result<(), SqlError> {
Err(SqlError::Other(
"this database does not support running a raw SQL script".into(),
))
}
async fn run_query(&self, sql: &str) -> Result<SqlRows, SqlError> {
let mut tx = self.begin_read_only().await?;
let result = tx.query(sql, &[]).await;
let _ = tx.rollback().await;
result
}
fn injects_session_context(&self) -> bool {
false
}
}
pub fn reject_reserved_session_writes(
sql: &str,
extra_namespaces: &[String],
) -> Result<(), SqlError> {
use sqlparser::dialect::GenericDialect;
use sqlparser::tokenizer::{Token, Tokenizer, Word};
const GUC_NAMESPACE: &str = "boatramp";
const MYSQL_VAR_PREFIX: &str = "@boatramp_";
let is_reserved_ns = |w: &str| w == GUC_NAMESPACE || extra_namespaces.iter().any(|n| n == w);
let refused = || {
Err(SqlError::Other(
"setting a reserved session key (boatramp.* / @boatramp_*, or the operator's \
rls_session tenant GUC) is not permitted from a handler: it is managed by \
rls_session and reserved for per-request tenant isolation"
.to_string(),
))
};
let dialect = GenericDialect {};
let Ok(raw) = Tokenizer::new(&dialect, sql).tokenize() else {
return refused();
};
let toks: Vec<&Token> = raw
.iter()
.filter(|t| !matches!(t, Token::Whitespace(_)))
.collect();
fn word_lc(tok: &Token) -> Option<String> {
match tok {
Token::Word(Word { value, .. }) => Some(value.to_ascii_lowercase()),
_ => None,
}
}
let is_reserved_var = |w: &str| w.starts_with(MYSQL_VAR_PREFIX);
if toks
.iter()
.any(|t| matches!(t, Token::DollarQuotedString(_)))
{
return refused();
}
if toks
.iter()
.any(|t| word_lc(t).is_some_and(|w| is_reserved_var(&w)))
{
return refused();
}
{
let leading = toks.first().and_then(|t| word_lc(t));
let has_word = |w: &str| toks.iter().any(|t| word_lc(t).as_deref() == Some(w));
let names_reserved = || {
toks.iter()
.any(|t| word_lc(t).is_some_and(|w| is_reserved_ns(&w) || is_reserved_var(&w)))
};
match leading.as_deref() {
Some("do") | Some("call") | Some("prepare") | Some("execute") => return refused(),
Some("create") | Some("alter") if has_word("function") || has_word("procedure") => {
return refused()
}
Some("alter")
if matches!(
toks.get(1).and_then(|t| word_lc(t)).as_deref(),
Some("role") | Some("database") | Some("user") | Some("system")
) && names_reserved() =>
{
return refused()
}
_ => {}
}
}
if let Some(first) = toks.first().and_then(|t| word_lc(t)) {
match first.as_str() {
"discard" => return refused(),
"reset" => {
if let Some(target) = toks.get(1).and_then(|t| word_lc(t)) {
if target == "all" || is_reserved_ns(&target) || is_reserved_var(&target) {
return refused();
}
}
}
"set" => {
let mut idx = 1;
if matches!(
toks.get(idx).and_then(|t| word_lc(t)).as_deref(),
Some("session") | Some("local")
) {
idx += 1;
}
if let Some(target) = toks.get(idx).and_then(|t| word_lc(t)) {
if is_reserved_ns(&target) || is_reserved_var(&target) {
return refused();
}
}
}
_ => {}
}
}
for (i, tok) in toks.iter().enumerate() {
if word_lc(tok).as_deref() != Some("set_config") {
continue;
}
if !matches!(toks.get(i + 1), Some(Token::LParen)) {
continue;
}
let arg0 = toks.get(i + 2);
let after = toks.get(i + 3);
match (arg0, after) {
(Some(Token::SingleQuotedString(s)), Some(Token::Comma | Token::RParen)) => {
let name = s.to_ascii_lowercase();
let ns_of = name.split('.').next().unwrap_or(&name);
if is_reserved_ns(ns_of) {
return refused();
}
}
_ => return refused(),
}
}
Ok(())
}
fn significant_words(script: &str) -> Option<Vec<String>> {
use sqlparser::dialect::GenericDialect;
use sqlparser::tokenizer::{Token, Tokenizer, Word};
let raw = Tokenizer::new(&GenericDialect {}, script).tokenize().ok()?;
Some(
raw.iter()
.filter_map(|t| match t {
Token::Word(Word { value, .. }) => Some(value.to_ascii_lowercase()),
_ => None,
})
.collect(),
)
}
pub fn script_has_txn_control(script: &str) -> bool {
let Some(words) = significant_words(script) else {
return true; };
let mut case_depth: u32 = 0;
for w in &words {
match w.as_str() {
"case" => case_depth += 1,
"end" => {
if case_depth == 0 {
return true; }
case_depth -= 1;
}
"begin" | "start" | "commit" | "rollback" | "abort" | "savepoint" | "release" => {
return true
}
_ => {}
}
}
false
}
pub fn script_references_word(script: &str, needle: &str) -> bool {
let needle = needle.to_ascii_lowercase();
match significant_words(script) {
Some(words) => words.contains(&needle),
None => true, }
}
pub fn script_has_create_extension(script: &str) -> bool {
match significant_words(script) {
Some(words) => words
.windows(2)
.any(|w| w[0] == "create" && w[1] == "extension"),
None => true, }
}
#[cfg(test)]
mod migration_guard_tests {
use super::{script_has_create_extension, script_has_txn_control, script_references_word};
#[test]
fn txn_control_is_comment_and_casing_immune() {
for s in [
"BEGIN; DROP TABLE t; COMMIT",
"commit",
"ROLLBACK",
"abort",
"SAVEPOINT x",
"RELEASE SAVEPOINT x",
"END",
] {
assert!(script_has_txn_control(s), "should flag: {s:?}");
}
for s in [
"/*c*/BEGIN",
"COMMIT-- trailing",
"COMMIT/**/",
"BEGIN--\nDROP TABLE t",
"cOmMiT",
] {
assert!(
script_has_txn_control(s),
"should flag (comment/casing): {s:?}"
);
}
}
#[test]
fn txn_control_does_not_false_positive() {
assert!(!script_has_txn_control(
"UPDATE t SET s = CASE WHEN x > 0 THEN 'a' ELSE 'b' END"
));
assert!(!script_has_txn_control(
"CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END $$ LANGUAGE plpgsql"
));
assert!(!script_has_txn_control(
"INSERT INTO log (msg) VALUES ('commit happened')"
));
assert!(!script_has_txn_control(
"CREATE TABLE t (id int primary key)"
));
}
#[test]
fn ledger_schema_reference_is_comment_and_quote_immune() {
assert!(script_references_word(
"SELECT * FROM boatramp_migrations.schema_migrations",
"boatramp_migrations"
));
assert!(script_references_word(
"DROP TABLE /*x*/ boatramp_migrations.schema_migrations",
"boatramp_migrations"
));
assert!(script_references_word(
"SELECT * FROM \"boatramp_migrations\".t",
"boatramp_migrations"
));
assert!(!script_references_word(
"INSERT INTO t (note) VALUES ('boatramp_migrations is host-owned')",
"boatramp_migrations"
));
assert!(!script_references_word(
"CREATE TABLE app.widget (id int)",
"boatramp_migrations"
));
}
#[test]
fn create_extension_is_comment_immune() {
assert!(script_has_create_extension("CREATE EXTENSION pgcrypto"));
assert!(script_has_create_extension(
"create/**/extension if not exists citext"
));
assert!(!script_has_create_extension("CREATE TABLE t (id int)"));
assert!(!script_has_create_extension(
"CREATE TABLE t (extension text)"
));
}
#[test]
fn unlexable_fails_closed() {
let bad = "SELECT 'unterminated";
assert!(script_has_txn_control(bad));
assert!(script_references_word(bad, "boatramp_migrations"));
assert!(script_has_create_extension(bad));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PreviewSqlMode {
#[default]
Empty,
Branch,
Shared,
}
#[async_trait]
pub trait SqlBackends: Send + Sync {
async fn database(
&self,
project: &str,
site: &str,
name: &str,
) -> Result<Arc<dyn SqlBackend>, SqlError>;
async fn preview_database(
&self,
project: &str,
site: &str,
name: &str,
preview: &str,
) -> Result<Arc<dyn SqlBackend>, SqlError> {
let qualified = crate::project::ProjectRef::new(project).qualified(site);
self.database(
crate::project::DEFAULT_PROJECT,
&format!("{qualified}/_preview/{preview}"),
name,
)
.await
}
}
#[async_trait]
pub trait OperatorSql: Send + Sync {
async fn exec_script(&self, project: &str, db: &str, script: &str) -> Result<(), SqlError>;
async fn query(&self, project: &str, db: &str, sql: &str) -> Result<SqlRows, SqlError>;
async fn ping(&self, project: &str, db: &str) -> Result<Vec<SqlPingReplica>, SqlError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqlPingReplica {
pub endpoint: String,
pub healthy: bool,
pub phase: String,
pub tcp_reachable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationStep {
pub id: String,
pub action: MigrationAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationAction {
Sql {
script: String,
no_transaction: bool,
},
Extension {
name: String,
},
Function {
name: String,
version: Option<String>,
args: Option<String>,
},
}
impl MigrationStep {
pub fn kind(&self) -> &'static str {
match self.action {
MigrationAction::Sql { .. } => "sql",
MigrationAction::Extension { .. } => "extension",
MigrationAction::Function { .. } => "function",
}
}
pub fn content_hash(&self) -> String {
let body = match &self.action {
MigrationAction::Sql {
script,
no_transaction,
} => format!("sql:{no_transaction}:{script}"),
MigrationAction::Extension { name } => format!("extension:{name}"),
MigrationAction::Function {
name,
version,
args,
} => format!(
"function:{name}:{}:{}",
version.as_deref().unwrap_or("active"),
args.as_deref().unwrap_or_default()
),
};
crate::deploy::sha256_hex(format!("{}\n{body}", self.id).as_bytes())
}
pub fn effective_hash(&self, resolved_blob: Option<&str>) -> String {
match resolved_blob {
Some(blob) => {
crate::deploy::sha256_hex(format!("{}\n{blob}", self.content_hash()).as_bytes())
}
None => self.content_hash(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AppliedMigration {
pub id: String,
pub ordinal: i64,
pub content_hash: String,
pub kind: String,
pub applied_at: String,
#[serde(default = "default_origin")]
pub origin: String,
}
fn default_origin() -> String {
LedgerOrigin::Apply.as_str().to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LedgerOrigin {
Apply,
Baseline,
}
impl LedgerOrigin {
pub fn as_str(self) -> &'static str {
match self {
Self::Apply => "apply",
Self::Baseline => "baseline",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct MigrationReport {
pub newly_applied: Vec<String>,
pub already_applied: Vec<String>,
pub pending: Vec<String>,
pub failed: Option<MigrationFailure>,
#[serde(default)]
pub kinds: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct MigrationFailure {
pub id: String,
pub error: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct MigrationStatus {
pub applied: Vec<AppliedMigration>,
}
#[derive(Debug, thiserror::Error)]
pub enum MigrationError {
#[error("migration backend not ready: {0}")]
Unavailable(String),
#[error("migration order diverged from the recorded ledger: {0}")]
PrefixDivergence(String),
#[error("migration {0} was modified after it was applied (content-hash mismatch)")]
ContentChanged(String),
#[error("extension {0:?} is not on the operator trusted-extension allowlist")]
ExtensionNotAllowed(String),
#[error(
"a sql migration step may not CREATE EXTENSION — use an extension step (allowlist-gated)"
)]
RawCreateExtension,
#[error("managed sql is not configured on this node")]
NotConfigured,
#[error(transparent)]
Sql(#[from] SqlError),
#[error("{0}")]
Other(String),
}
#[derive(Debug, thiserror::Error)]
pub enum MigrateDdlError {
#[error("migrate: the migration-ledger schema is host-owned and may not be touched by a migration step")]
LedgerProtected,
#[error("migrate: a migration step may not issue its own BEGIN/COMMIT/ROLLBACK — each exec auto-commits")]
TxnControl,
#[error("migrate: {0}")]
Sql(String),
}
#[async_trait]
pub trait MigrateDdl: Send + Sync {
async fn exec(&self, script: &str) -> Result<(), MigrateDdlError>;
async fn exec_batch(&self, scripts: Vec<String>) -> Result<(), MigrateDdlError>;
async fn query(&self, sql: &str) -> Result<SqlRows, MigrateDdlError>;
}
#[derive(Debug)]
pub enum SubstrateStepOutcome {
Applied,
Failed(String),
}
#[async_trait]
pub trait MigrationSubstrate: Send + Sync {
async fn preflight(
&self,
project: &str,
db: &str,
) -> Result<Vec<AppliedMigration>, MigrationError>;
async fn apply_substrate_step(
&self,
project: &str,
db: &str,
step: &MigrationStep,
ordinal: usize,
effective_hash: &str,
) -> Result<SubstrateStepOutcome, MigrationError>;
async fn record(
&self,
project: &str,
db: &str,
step: &MigrationStep,
ordinal: usize,
effective_hash: &str,
origin: LedgerOrigin,
) -> Result<(), MigrationError>;
async fn owner_ddl(
&self,
project: &str,
db: &str,
) -> Result<std::sync::Arc<dyn MigrateDdl>, MigrationError>;
}
#[async_trait]
pub trait TenantDeprovisioner: Send + Sync {
async fn deprovision_project(&self, project: &str);
async fn deprovision_site(&self, project: &str, site: &str);
}
#[async_trait]
pub trait SqlTransaction: Send {
async fn query(&mut self, sql: &str, params: &[SqlValue]) -> Result<SqlRows, SqlError>;
async fn execute(&mut self, sql: &str, params: &[SqlValue]) -> Result<u64, SqlError>;
async fn commit(self: Box<Self>) -> Result<(), SqlError>;
async fn rollback(self: Box<Self>) -> Result<(), SqlError>;
}
#[cfg(test)]
mod reserved_session_writes_tests {
use super::reject_reserved_session_writes as check;
fn rejected(sql: &str) -> bool {
check(sql, &[]).is_err()
}
fn rejected_with_app(sql: &str) -> bool {
check(sql, &["app".to_string()]).is_err()
}
#[test]
fn operator_rls_guc_is_rejected_only_when_its_namespace_is_reserved() {
assert!(rejected_with_app("SET app.tenant_id = 'victim'"));
assert!(rejected_with_app("set local app.tenant_id = 'victim'"));
assert!(rejected_with_app(
"SELECT set_config('app.tenant_id','victim',false)"
));
assert!(rejected_with_app("RESET app.tenant_id"));
assert!(!rejected("SET app.tenant_id = 'x'"));
assert!(!rejected("SELECT set_config('app.tenant_id','x',true)"));
assert!(rejected_with_app("SET boatramp.project = 'x'"));
}
#[test]
fn set_config_on_reserved_guc_is_rejected() {
assert!(rejected(
"SELECT set_config('boatramp.project','victim',false)"
));
assert!(rejected("select set_config('boatramp.site', 'x', true)"));
assert!(rejected(
"SELECT set_config ( 'boatramp.project' , 'v', false )"
));
assert!(rejected(
"SELECT set_config(\"boatramp.project\", 'v', false)"
));
assert!(rejected(
"SELECT set_config('search_path','app',false), \
set_config('boatramp.project','v',false)"
));
}
#[test]
fn set_reserved_guc_is_rejected() {
assert!(rejected("SET boatramp.project = 'victim'"));
assert!(rejected("set boatramp.project='victim'")); assert!(rejected("SET SESSION boatramp.site = 'x'"));
assert!(rejected("SET LOCAL boatramp.project TO 'x'"));
}
#[test]
fn set_reserved_mysql_var_is_rejected() {
assert!(rejected("SET @boatramp_project = 'victim'"));
assert!(rejected("set @boatramp_site='x'"));
assert!(rejected("SET @boatramp_project := 'x'")); assert!(rejected("SET SESSION @boatramp_project = 'x'"));
}
#[test]
fn reset_and_discard_of_reserved_state_is_rejected() {
assert!(rejected("RESET boatramp.project"));
assert!(rejected("RESET ALL")); assert!(rejected("DISCARD ALL"));
assert!(rejected("discard all"));
}
#[test]
fn unrelated_set_statements_are_allowed() {
assert!(!rejected("SET statement_timeout = 5000"));
assert!(!rejected("SET search_path TO app, public"));
assert!(!rejected("SET SESSION time_zone = '+00:00'"));
assert!(!rejected("SET @my_var = 1")); assert!(!rejected("RESET statement_timeout"));
}
#[test]
fn a_select_mentioning_set_in_an_identifier_is_allowed() {
assert!(!rejected("SELECT settings FROM boatramp_projects"));
assert!(!rejected(
"SELECT * FROM offset_table WHERE reset_at > now()"
));
assert!(!rejected("SELECT * FROM t WHERE name = 'boatramp.project'"));
}
#[test]
fn set_config_on_a_non_reserved_guc_is_allowed() {
assert!(!rejected("SELECT set_config('search_path','app',false)"));
assert!(!rejected(
"SELECT set_config('statement_timeout', '5000', true)"
));
}
#[test]
fn inline_comment_splitting_the_keyword_is_rejected() {
assert!(rejected("SET/*x*/ boatramp.project='x'"));
assert!(rejected("set_config/*c*/('boatramp.project','x')"));
}
#[test]
fn leading_comment_before_set_is_rejected() {
assert!(rejected("/*c*/SET boatramp.project='x'"));
assert!(rejected("/* hi */ set_config('boatramp.site','x')"));
}
#[test]
fn set_config_with_concatenated_name_is_rejected() {
assert!(rejected(
"SELECT set_config('boat'||'ramp.project','x',false)"
));
assert!(rejected(
"SELECT set_config('boatramp.'||'project','x',false)"
));
}
#[test]
fn mysql_quoted_reserved_var_is_rejected() {
assert!(rejected("SET `@boatramp_project`=1"));
assert!(rejected("SET @`boatramp_project`=1"));
}
#[test]
fn case_variants_are_rejected() {
assert!(rejected("sEt boatramp.project=1"));
assert!(rejected("SeT_config('boatramp.project','x')"));
}
#[test]
fn set_config_edge_forms_are_rejected() {
assert!(rejected(
"SELECT set_config ( 'boatramp.project' , 'v', false )"
));
assert!(rejected(
"SELECT set_config('search_path','app',false), \
set_config('boatramp.project','v',false)"
));
}
#[test]
fn dollar_quoted_do_block_reserved_write_is_rejected() {
assert!(rejected(
"DO $$ BEGIN PERFORM set_config('boatramp.project','victim',false); END $$;"
));
assert!(rejected(
"DO $$ BEGIN SET boatramp.project = 'victim'; END $$;"
));
assert!(rejected(
"DO $tag$ PERFORM set_config('boatramp.project','v',false); $tag$;"
));
assert!(rejected(
"SELECT set_config($$boatramp.project$$, 'v', false)"
));
}
#[test]
fn procedural_and_persistent_constructs_are_rejected() {
assert!(rejected(
"DO 'BEGIN PERFORM set_config(''boatramp.project'',''v'',false); END'"
));
assert!(rejected("CALL do_evil()"));
assert!(rejected(
"CREATE FUNCTION e() RETURNS void AS $$ SELECT set_config('boatramp.project','v',false) $$ LANGUAGE sql"
));
assert!(rejected(
"CREATE FUNCTION e() RETURNS void AS 'BEGIN PERFORM set_config(''boatramp.project'',''v'',false); END' LANGUAGE plpgsql"
));
assert!(rejected(
"CREATE OR REPLACE PROCEDURE p() LANGUAGE sql AS $$ SELECT 1 $$"
));
assert!(rejected(
"ALTER ROLE tenant_role SET boatramp.project = 'victim'"
));
assert!(rejected("ALTER DATABASE app SET boatramp.site = 'victim'"));
}
#[test]
fn mysql_reserved_var_anywhere_is_rejected() {
assert!(rejected("SET @x=1, @boatramp_project='victim'"));
assert!(rejected("SET @a=1, @b=2, @boatramp_project='victim'"));
assert!(rejected("SET @x:=1, @boatramp_project:='victim'"));
assert!(rejected("SELECT 'victim' INTO @boatramp_project"));
assert!(rejected("SELECT 'victim' AS v INTO @boatramp_project"));
assert!(rejected("SELECT 1,'victim' INTO @junk, @boatramp_project"));
assert!(rejected("select 'victim' into @boatramp_project"));
assert!(rejected("SELECT 'v' INTO @boatramp_site"));
}
#[test]
fn prepared_statement_indirection_is_rejected() {
assert!(rejected(
"PREPARE s FROM 'SET @boatramp_project=''victim'''"
));
assert!(rejected("EXECUTE s"));
assert!(rejected(
"prepare s from 'SELECT ''v'' INTO @boatramp_site'"
));
}
#[test]
fn legit_set_and_set_config_forms_still_pass() {
assert!(!rejected("SET statement_timeout = '5s'"));
assert!(!rejected("SET search_path TO myschema"));
assert!(!rejected("SET SESSION time_zone = '+00:00'"));
assert!(!rejected("SET @my_var = 1"));
assert!(!rejected("RESET statement_timeout"));
assert!(!rejected("set_config('search_path','x',false)"));
assert!(!rejected("set_config('statement_timeout','5s',true)"));
assert!(!rejected(
"SELECT settings FROM t WHERE k = 'boatramp.project'"
));
assert!(!rejected("SELECT * FROM orders WHERE id = $1"));
assert!(!rejected("INSERT INTO orders (id, total) VALUES ($1, $2)"));
assert!(!rejected("UPDATE orders SET total = $1 WHERE id = $2"));
assert!(!rejected(
"CREATE TABLE orders (id bigint primary key, total numeric)"
));
assert!(!rejected("ALTER TABLE orders ADD COLUMN note text"));
assert!(!rejected("SET @x = 1, @y = 2"));
assert!(!rejected("SELECT 42 INTO @myvar"));
assert!(!rejected("SELECT total INTO @t FROM orders WHERE id = $1"));
}
}