fix(editor): hide commit/rollback for clean mysql/pg manual transactions
Closes #9018
This commit is contained in:
1 parent
91b8fa0aa8
commit
e9be6d0419
15 files changed
+1016
-137
No files matched your search
@@ -232,14 +232,15 @@ pub struct ExecuteMultiResult {
|
||||
pub error: Option<crate::backend_error::BackendError>,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub server_message: bool,
|
||||
/// Oracle-only manual-transaction UX metadata: true only for a statement
|
||||
/// proven to be an ordinary top-level read. Absent/false for every other
|
||||
/// Oracle statement and every non-Oracle execution. Not part of the
|
||||
/// reusable database-result model (`db::QueryResult`).
|
||||
/// Manual-transaction UX metadata for sticky proven-read-only dialects
|
||||
/// (Oracle, OceanBase-Oracle, MySQL, PostgreSQL): true only for a statement
|
||||
/// proven to be an ordinary read by that dialect's strict heuristic.
|
||||
/// Absent/false for unproven statements and non-participating dialects.
|
||||
/// Not part of the reusable database-result model (`db::QueryResult`).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub manual_transaction_proven_read_only: bool,
|
||||
/// Oracle-only manual-transaction UX metadata: true on the synthetic
|
||||
/// successful result when the manual-execution splitter found zero
|
||||
/// Manual-transaction UX metadata for the same dialects: true on the
|
||||
/// synthetic successful result when the manual-execution splitter found zero
|
||||
/// statements (empty/whitespace/comments-only script). Lets the frontend
|
||||
/// treat it as a no-op rather than an unproven statement.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
@@ -5378,6 +5379,45 @@ fn mysql_error_is_syntax_error(error: &mysql_async::Error) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute per-execution-statement proven-read-only markers for the sticky
|
||||
/// manual-transaction UX (#7122 Oracle, #9018 MySQL/PostgreSQL). The user-facing
|
||||
/// classification SQL is split with the same dialect-aware splitter as the
|
||||
/// execution SQL and paired by count/position; any mismatch is fail-closed (no
|
||||
/// markers). Oracle/OceanBase-Oracle keep the lexical classifier, MySQL and
|
||||
/// PostgreSQL use the strict `sql_risk` proof; every other dialect is unproven.
|
||||
fn classify_manual_transaction_statements(
|
||||
database_type: Option<DatabaseType>,
|
||||
execution_statement_count: usize,
|
||||
classification_sql: Option<&str>,
|
||||
) -> Vec<bool> {
|
||||
let Some(database_type) = database_type.filter(|database_type| {
|
||||
matches!(
|
||||
database_type,
|
||||
DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Mysql | DatabaseType::Postgres
|
||||
)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(classification_sql) = classification_sql else {
|
||||
return Vec::new();
|
||||
};
|
||||
let user_statements = crate::sql::split_sql_statements_for_database(classification_sql, database_type);
|
||||
let paired = user_statements.len() == execution_statement_count && !user_statements.is_empty();
|
||||
if !paired {
|
||||
return Vec::new();
|
||||
}
|
||||
user_statements
|
||||
.iter()
|
||||
.map(|statement| match database_type {
|
||||
DatabaseType::Mysql | DatabaseType::Postgres => {
|
||||
crate::sql_risk::prove_read_only_for_database(statement, database_type)
|
||||
== crate::sql_risk::ReadProof::ProvenReadOnly
|
||||
}
|
||||
_ => is_oracle_proven_read_only_statement(statement),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn begin_transaction_session(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
@@ -5688,7 +5728,7 @@ pub async fn execute_in_manual_transaction_with_options(
|
||||
},
|
||||
);
|
||||
if statements.is_empty() {
|
||||
// Oracle-only UX marker: the no-op is Core's decision that the script
|
||||
// Sticky-dialect UX marker: the no-op is Core's decision that the script
|
||||
// (empty/whitespace/comments-only) has no statements, so the frontend
|
||||
// must not treat it as an unproven statement. Every other database
|
||||
// receives the plain empty result.
|
||||
@@ -5696,7 +5736,10 @@ pub async fn execute_in_manual_transaction_with_options(
|
||||
empty_query_result(0),
|
||||
options.table_data_preview,
|
||||
);
|
||||
if matches!(db_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) {
|
||||
if matches!(
|
||||
db_type,
|
||||
Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Mysql | DatabaseType::Postgres)
|
||||
) {
|
||||
result = result.with_manual_transaction_no_statement();
|
||||
}
|
||||
return Ok(vec![result]);
|
||||
@@ -5752,30 +5795,14 @@ pub async fn execute_in_manual_transaction_with_options(
|
||||
let row_limit = options.max_rows.unwrap_or(MAX_ROWS).max(1);
|
||||
let mut results = Vec::with_capacity(statements.len());
|
||||
|
||||
// Oracle-only classification pairing. The core splits both the execution
|
||||
// Sticky-dialect classification pairing. The core splits both the execution
|
||||
// SQL and, when present, the user-facing classification SQL with the same
|
||||
// Oracle-aware splitter. A marker is emitted only when both lists have the
|
||||
// dialect-aware splitter. A marker is emitted only when both lists have the
|
||||
// same non-zero count and every paired user statement is proven read-only;
|
||||
// any mismatch is fail-closed (no marker). This is deliberately a
|
||||
// trust-boundary count/position pairing, not a SQL-equivalence parser.
|
||||
let classification: Vec<bool> = if let Some(dialect) =
|
||||
db_type.filter(|db_type| matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
|
||||
{
|
||||
match options.classification_sql.as_deref() {
|
||||
Some(classification_sql) => {
|
||||
let user_statements = crate::sql::split_sql_statements_for_database(classification_sql, dialect);
|
||||
let paired = user_statements.len() == statements.len() && !user_statements.is_empty();
|
||||
if paired {
|
||||
user_statements.iter().map(|statement| is_oracle_proven_read_only_statement(statement)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let classification: Vec<bool> =
|
||||
classify_manual_transaction_statements(db_type, statements.len(), options.classification_sql.as_deref());
|
||||
|
||||
let mut conn = connection.lock().await;
|
||||
for (i, statement) in statements.iter().enumerate() {
|
||||
@@ -10492,6 +10519,44 @@ for line in sys.stdin:
|
||||
assert!(second_params.get("sql").is_none());
|
||||
}
|
||||
|
||||
/// Spawns a fake Python agent and registers a manual transaction session in
|
||||
/// the app state so `execute_in_manual_transaction_with_options` can run
|
||||
/// end to end without a live database.
|
||||
#[test]
|
||||
fn manual_transaction_classification_pairs_statements_and_fails_closed() {
|
||||
// MySQL/PostgreSQL route through the strict sql_risk proof.
|
||||
assert_eq!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, Some("SELECT 1")), vec![true]);
|
||||
assert_eq!(
|
||||
classify_manual_transaction_statements(Some(DatabaseType::Postgres), 1, Some("SELECT * FROM users")),
|
||||
vec![true]
|
||||
);
|
||||
// Mixed script: every statement is classified individually.
|
||||
assert_eq!(
|
||||
classify_manual_transaction_statements(Some(DatabaseType::Mysql), 2, Some("SELECT 1; DELETE FROM t")),
|
||||
vec![true, false]
|
||||
);
|
||||
// Session-state writes fail the proof (SELECT ... INTO @var).
|
||||
assert_eq!(
|
||||
classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, Some("SELECT 1 INTO @x")),
|
||||
vec![false]
|
||||
);
|
||||
// Count mismatch, missing classification SQL, non-participating dialects
|
||||
// and unknown connections are all fail-closed (no markers).
|
||||
assert!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 3, Some("SELECT 1")).is_empty());
|
||||
assert!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, None).is_empty());
|
||||
assert!(classify_manual_transaction_statements(Some(DatabaseType::Doris), 1, Some("SELECT 1")).is_empty());
|
||||
assert!(classify_manual_transaction_statements(None, 1, Some("SELECT 1")).is_empty());
|
||||
// Oracle keeps its lexical classifier and its pairing behavior.
|
||||
assert_eq!(
|
||||
classify_manual_transaction_statements(Some(DatabaseType::Oracle), 1, Some("SELECT * FROM EMP")),
|
||||
vec![true]
|
||||
);
|
||||
assert_eq!(
|
||||
classify_manual_transaction_statements(Some(DatabaseType::OceanbaseOracle), 1, Some("DELETE FROM EMP")),
|
||||
vec![false]
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawns a fake Python agent and registers a manual transaction session in
|
||||
/// the app state so `execute_in_manual_transaction_with_options` can run
|
||||
/// end to end without a live database.
|
||||
|
||||
@@ -444,7 +444,7 @@ fn contains_keyword_sequence(sql: &str, first: &str, second: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn contains_unquoted_keyword(sql: &str, dialect: &dyn sqlparser::dialect::Dialect, keyword: &str) -> bool {
|
||||
pub(crate) fn contains_unquoted_keyword(sql: &str, dialect: &dyn sqlparser::dialect::Dialect, keyword: &str) -> bool {
|
||||
Tokenizer::new(dialect, sql).tokenize().is_ok_and(|tokens| {
|
||||
tokens.into_iter().any(|token| {
|
||||
matches!(token, Token::Word(word) if word.quote_style.is_none() && word.value.eq_ignore_ascii_case(keyword))
|
||||
|
||||
+488
-16
@@ -3,7 +3,7 @@ use std::ops::ControlFlow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlparser::ast::{
|
||||
visit_expressions, BinaryOperator, Expr, FromTable, OnConflictAction, OnInsert, Query, SetExpr, SqliteOnConflict,
|
||||
Statement, UnaryOperator, Value,
|
||||
Statement, TableFactor, UnaryOperator, Value, Visit, Visitor,
|
||||
};
|
||||
use sqlparser::dialect::{
|
||||
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
|
||||
@@ -86,21 +86,7 @@ fn classify_statement(stmt: &Statement, detect_select_into: bool) -> SqlRisk {
|
||||
Statement::ExplainTable { .. } => SqlRisk::ReadOnly,
|
||||
|
||||
// Show/Describe variants
|
||||
Statement::ShowTables { .. }
|
||||
| Statement::ShowColumns { .. }
|
||||
| Statement::ShowCatalogs { .. }
|
||||
| Statement::ShowDatabases { .. }
|
||||
| Statement::ShowSchemas { .. }
|
||||
| Statement::ShowViews { .. }
|
||||
| Statement::ShowFunctions { .. }
|
||||
| Statement::ShowCreate { .. }
|
||||
| Statement::ShowVariable { .. }
|
||||
| Statement::ShowVariables { .. }
|
||||
| Statement::ShowStatus { .. }
|
||||
| Statement::ShowProcessList { .. }
|
||||
| Statement::ShowCharset(_)
|
||||
| Statement::ShowObjects(_)
|
||||
| Statement::ShowCollation { .. } => SqlRisk::ReadOnly,
|
||||
stmt if is_show_read_only_statement(stmt) => SqlRisk::ReadOnly,
|
||||
|
||||
// Write operations
|
||||
Statement::Insert { .. } | Statement::Update { .. } | Statement::Delete { .. } | Statement::Merge { .. } => {
|
||||
@@ -141,6 +127,31 @@ fn classify_statement(stmt: &Statement, detect_select_into: bool) -> SqlRisk {
|
||||
}
|
||||
}
|
||||
|
||||
/// SHOW/DESCRIBE-style statement variants sqlparser models as reads. Shared by
|
||||
/// the risk classifier and the manual-transaction proof (#9018) so the two
|
||||
/// cannot drift; unparseable SHOW variants fail closed upstream in the proof
|
||||
/// (parse error) and to the keyword fallback in the risk classifier.
|
||||
fn is_show_read_only_statement(statement: &Statement) -> bool {
|
||||
matches!(
|
||||
statement,
|
||||
Statement::ShowTables { .. }
|
||||
| Statement::ShowColumns { .. }
|
||||
| Statement::ShowCatalogs { .. }
|
||||
| Statement::ShowDatabases { .. }
|
||||
| Statement::ShowSchemas { .. }
|
||||
| Statement::ShowViews { .. }
|
||||
| Statement::ShowFunctions { .. }
|
||||
| Statement::ShowCreate { .. }
|
||||
| Statement::ShowVariable { .. }
|
||||
| Statement::ShowVariables { .. }
|
||||
| Statement::ShowStatus { .. }
|
||||
| Statement::ShowProcessList { .. }
|
||||
| Statement::ShowCharset(_)
|
||||
| Statement::ShowObjects(_)
|
||||
| Statement::ShowCollation { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn statement_is_dangerous(stmt: &Statement, detect_select_into: bool) -> bool {
|
||||
match stmt {
|
||||
// `USE` only changes connection-local state. MCP separately requires
|
||||
@@ -876,10 +887,471 @@ fn sql_contains_locking_clause(sql: &str, dialect: &dyn sqlparser::dialect::Dial
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strict manual-transaction read-only proof (#9018)
|
||||
//
|
||||
// Unlike `classify_sql_risk*` (keyword fallback on parse failure may answer
|
||||
// ReadOnly), the proof below is fail-closed end to end: the parse must succeed
|
||||
// and yield exactly one statement, no write-capable construct may appear, and
|
||||
// every called function must be an allowlisted pure builtin. The outcome only
|
||||
// drives commit/rollback button visibility in manual transaction mode — it is
|
||||
// a heuristic, never a security boundary (PG custom operators/casts and MySQL
|
||||
// UDFs can hide arbitrary side effects static syntax cannot see).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Strict manual-transaction UX proof outcome. UI-only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadProof {
|
||||
/// DBX's strict heuristic is satisfied for this statement.
|
||||
ProvenReadOnly,
|
||||
/// Anything unprovable. Buttons stay visible; execution is unaffected.
|
||||
Unproven,
|
||||
}
|
||||
|
||||
/// Pure built-in functions accepted by the MySQL proof. Fail-closed: a call to
|
||||
/// anything not listed (UDFs, session-state setters like `LAST_INSERT_ID(expr)`,
|
||||
/// `SLEEP`/`GET_LOCK`, ...) is Unproven. Only ever grows by review.
|
||||
const MYSQL_PROOF_SAFE_FUNCTIONS: &[&str] = &[
|
||||
"abs",
|
||||
"ascii",
|
||||
"avg",
|
||||
"bin",
|
||||
"ceiling",
|
||||
"char_length",
|
||||
"character_length",
|
||||
"coalesce",
|
||||
"concat",
|
||||
"concat_ws",
|
||||
"conv",
|
||||
"count",
|
||||
"crc32",
|
||||
"curdate",
|
||||
"curtime",
|
||||
"current_date",
|
||||
"current_time",
|
||||
"current_timestamp",
|
||||
"date_format",
|
||||
"datediff",
|
||||
"dayname",
|
||||
"dayofmonth",
|
||||
"dayofweek",
|
||||
"dayofyear",
|
||||
"exp",
|
||||
"floor",
|
||||
"format",
|
||||
"greatest",
|
||||
"hex",
|
||||
"hour",
|
||||
"if",
|
||||
"ifnull",
|
||||
"inet_aton",
|
||||
"inet_ntoa",
|
||||
"instr",
|
||||
"isnull",
|
||||
"json_extract",
|
||||
"json_length",
|
||||
"json_unquote",
|
||||
"json_valid",
|
||||
"last_day",
|
||||
"lcase",
|
||||
"least",
|
||||
"left",
|
||||
"length",
|
||||
"ln",
|
||||
"locate",
|
||||
"log",
|
||||
"log10",
|
||||
"log2",
|
||||
"lower",
|
||||
"lpad",
|
||||
"ltrim",
|
||||
"max",
|
||||
"md5",
|
||||
"microsecond",
|
||||
"min",
|
||||
"minute",
|
||||
"mod",
|
||||
"month",
|
||||
"monthname",
|
||||
"now",
|
||||
"nullif",
|
||||
"oct",
|
||||
"ord",
|
||||
"position",
|
||||
"pow",
|
||||
"power",
|
||||
"quarter",
|
||||
"rand",
|
||||
"repeat",
|
||||
"replace",
|
||||
"reverse",
|
||||
"right",
|
||||
"round",
|
||||
"rpad",
|
||||
"rtrim",
|
||||
"second",
|
||||
"sha",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sign",
|
||||
"space",
|
||||
"sqrt",
|
||||
"str_to_date",
|
||||
"substring",
|
||||
"substr",
|
||||
"time_format",
|
||||
"timediff",
|
||||
"timestampadd",
|
||||
"timestampdiff",
|
||||
"truncate",
|
||||
"unhex",
|
||||
"unix_timestamp",
|
||||
"upper",
|
||||
"ucase",
|
||||
"utc_date",
|
||||
"utc_time",
|
||||
"utc_timestamp",
|
||||
"uuid",
|
||||
"week",
|
||||
"weekday",
|
||||
"year",
|
||||
];
|
||||
|
||||
/// Pure built-in functions accepted by the PostgreSQL proof. Same contract as
|
||||
/// `MYSQL_PROOF_SAFE_FUNCTIONS`; sequence mutators/readers (`nextval`/`setval`),
|
||||
/// `pg_sleep`, advisory-lock and large-object functions are deliberately absent.
|
||||
const POSTGRES_PROOF_SAFE_FUNCTIONS: &[&str] = &[
|
||||
"abs",
|
||||
"age",
|
||||
"array_agg",
|
||||
"array_length",
|
||||
"ascii",
|
||||
"avg",
|
||||
"btrim",
|
||||
"cardinality",
|
||||
"ceil",
|
||||
"ceiling",
|
||||
"char_length",
|
||||
"character_length",
|
||||
"chr",
|
||||
"coalesce",
|
||||
"concat",
|
||||
"concat_ws",
|
||||
"count",
|
||||
"current_catalog",
|
||||
"current_date",
|
||||
"current_schema",
|
||||
"current_setting",
|
||||
"current_time",
|
||||
"current_timestamp",
|
||||
"current_user",
|
||||
"date_part",
|
||||
"date_trunc",
|
||||
"decode",
|
||||
"div",
|
||||
"encode",
|
||||
"every",
|
||||
"exp",
|
||||
"floor",
|
||||
"format",
|
||||
"gen_random_uuid",
|
||||
"greatest",
|
||||
"left",
|
||||
"length",
|
||||
"localtime",
|
||||
"localtimestamp",
|
||||
"log",
|
||||
"lower",
|
||||
"lpad",
|
||||
"ltrim",
|
||||
"max",
|
||||
"md5",
|
||||
"min",
|
||||
"mod",
|
||||
"now",
|
||||
"nullif",
|
||||
"position",
|
||||
"power",
|
||||
"repeat",
|
||||
"replace",
|
||||
"reverse",
|
||||
"right",
|
||||
"round",
|
||||
"rpad",
|
||||
"rtrim",
|
||||
"sha224",
|
||||
"sha256",
|
||||
"sha384",
|
||||
"sha512",
|
||||
"sign",
|
||||
"split_part",
|
||||
"sqrt",
|
||||
"starts_with",
|
||||
"strpos",
|
||||
"string_agg",
|
||||
"substr",
|
||||
"substring",
|
||||
"to_char",
|
||||
"to_date",
|
||||
"to_hex",
|
||||
"to_number",
|
||||
"to_timestamp",
|
||||
"trunc",
|
||||
"unnest",
|
||||
"upper",
|
||||
"version",
|
||||
];
|
||||
|
||||
/// Prove that a single SQL statement is, by DBX's strict heuristic, an
|
||||
/// ordinary read. Only MySQL/PostgreSQL take part in this proof; every other
|
||||
/// database type is Unproven so its manual-transaction toolbar keeps the
|
||||
/// legacy behavior. Oracle keeps its own lexical classifier
|
||||
/// (`is_oracle_proven_read_only_statement`) — do not reroute it here.
|
||||
pub fn prove_read_only_for_database(sql: &str, database_type: DatabaseType) -> ReadProof {
|
||||
match database_type {
|
||||
DatabaseType::Mysql => prove_read_only_statement(sql, "mysql", MYSQL_PROOF_SAFE_FUNCTIONS),
|
||||
DatabaseType::Postgres => prove_read_only_statement(sql, "postgres", POSTGRES_PROOF_SAFE_FUNCTIONS),
|
||||
_ => ReadProof::Unproven,
|
||||
}
|
||||
}
|
||||
|
||||
fn prove_read_only_statement(sql: &str, dialect: &str, allowed_functions: &[&str]) -> ReadProof {
|
||||
let database_type = if dialect == "mysql" { DatabaseType::Mysql } else { DatabaseType::Postgres };
|
||||
// Lexical rejections that must not depend on parser support: dialect-specific
|
||||
// write syntax (executable comments, INTO OUTFILE/DUMPFILE, PostgreSQL
|
||||
// SELECT INTO) and MySQL session writes the parser models inconsistently
|
||||
// (SELECT @a := 1, SELECT ... INTO @var — INTO may sit in several positions).
|
||||
// `supports_select_into_table_creation` is deliberately NOT reused here: it
|
||||
// answers "SELECT INTO table creation" risk and excludes MySQL on purpose,
|
||||
// while the proof must reject any INTO target as session state.
|
||||
let cleaned = crate::query_execution_sql::strip_sql_comments_and_literals(sql);
|
||||
if cleaned.trim().is_empty() || crate::query_execution_sql::has_dialect_specific_write(sql, database_type) {
|
||||
return ReadProof::Unproven;
|
||||
}
|
||||
let parser_dialect = resolve_dialect(dialect);
|
||||
if dialect == "mysql"
|
||||
&& (cleaned.contains(":=")
|
||||
|| crate::query_execution_sql::contains_unquoted_keyword(&cleaned, parser_dialect.as_ref(), "INTO"))
|
||||
{
|
||||
return ReadProof::Unproven;
|
||||
}
|
||||
// The strict proof has NO keyword fallback: parse failure is Unproven, and
|
||||
// the API stays fail-closed even for callers that skipped pre-splitting.
|
||||
let Ok(statements) = Parser::parse_sql(parser_dialect.as_ref(), sql) else {
|
||||
return ReadProof::Unproven;
|
||||
};
|
||||
let [statement] = statements.as_slice() else {
|
||||
return ReadProof::Unproven;
|
||||
};
|
||||
prove_statement(statement, allowed_functions)
|
||||
}
|
||||
|
||||
fn prove_statement(statement: &Statement, allowed_functions: &[&str]) -> ReadProof {
|
||||
match statement {
|
||||
Statement::Query(query) => {
|
||||
// Reuses the risk engine's structural walk: writable CTEs, DML set
|
||||
// branches, locking clauses, SELECT INTO and denylisted side-effect
|
||||
// functions. detect_select_into is always true — a proof is about a
|
||||
// clean session, not table-creation risk.
|
||||
if query_is_write_capable(query, true) {
|
||||
return ReadProof::Unproven;
|
||||
}
|
||||
let mut visitor = ProofFunctionVisitor { allowed_functions, rejected: false };
|
||||
let _ = query.visit(&mut visitor);
|
||||
if visitor.rejected {
|
||||
ReadProof::Unproven
|
||||
} else {
|
||||
ReadProof::ProvenReadOnly
|
||||
}
|
||||
}
|
||||
// Plain EXPLAIN never executes the statement; EXPLAIN ANALYZE does.
|
||||
Statement::Explain { analyze: false, statement, .. } => prove_statement(statement, allowed_functions),
|
||||
Statement::Explain { .. } => ReadProof::Unproven,
|
||||
// MySQL `DESC t` / `DESCRIBE t`.
|
||||
Statement::ExplainTable { .. } => ReadProof::ProvenReadOnly,
|
||||
statement if is_show_read_only_statement(statement) => ReadProof::ProvenReadOnly,
|
||||
_ => ReadProof::Unproven,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-statement allowlist scan. Deliberately uses the `visitor`-feature walk
|
||||
/// (covers CTE bodies, FROM subqueries, LATERAL derived tables and ORDER BY
|
||||
/// expressions) instead of `visit_expressions`, which skips FROM subqueries.
|
||||
/// Window functions and table functions (`FROM generate_series(...)`) are
|
||||
/// rejected outright in v1 — fail-closed, relaxed only by review.
|
||||
struct ProofFunctionVisitor<'a> {
|
||||
allowed_functions: &'a [&'a str],
|
||||
rejected: bool,
|
||||
}
|
||||
|
||||
impl Visitor for ProofFunctionVisitor<'_> {
|
||||
type Break = ();
|
||||
|
||||
fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
|
||||
if let Expr::Function(function) = expr {
|
||||
if function.over.is_some() {
|
||||
self.rejected = true;
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
let parts = &function.name.0;
|
||||
let allowed = parts.len() == 1
|
||||
&& parts.last().and_then(|part| part.as_ident()).is_some_and(|ident| {
|
||||
self.allowed_functions.iter().any(|candidate| ident.value.eq_ignore_ascii_case(candidate))
|
||||
});
|
||||
if !allowed {
|
||||
self.rejected = true;
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
}
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
|
||||
fn pre_visit_table_factor(&mut self, table_factor: &TableFactor) -> ControlFlow<()> {
|
||||
if let TableFactor::Table { args: Some(_), .. } = table_factor {
|
||||
self.rejected = true;
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mysql_proof_accepts_plain_reads_and_allowlisted_functions() {
|
||||
for sql in [
|
||||
"SELECT * FROM users",
|
||||
"SELECT COUNT(*) FROM orders",
|
||||
"SELECT id, name FROM users WHERE id IN (1, 2, 3)",
|
||||
"SELECT id, name FROM users WHERE id IN (SELECT user_id FROM roles) ORDER BY created_at LIMIT 10",
|
||||
"SELECT 'update' FROM t",
|
||||
"SELECT a FROM t UNION ALL SELECT b FROM u",
|
||||
"WITH totals AS (SELECT COUNT(*) AS n FROM orders) SELECT n FROM totals",
|
||||
"SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users",
|
||||
"SELECT id FROM users ORDER BY rand() LIMIT 1",
|
||||
"SHOW TABLES",
|
||||
"SHOW CREATE TABLE users",
|
||||
"EXPLAIN SELECT * FROM users",
|
||||
"DESC users",
|
||||
"DESCRIBE users",
|
||||
"-- only a comment header\nSELECT id FROM users",
|
||||
"SELECT 1",
|
||||
] {
|
||||
assert_eq!(
|
||||
prove_read_only_for_database(sql, DatabaseType::Mysql),
|
||||
ReadProof::ProvenReadOnly,
|
||||
"expected proven: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_proof_rejects_session_writes_locking_and_unknown_functions() {
|
||||
for sql in [
|
||||
"SELECT @a := 1",
|
||||
"SELECT 1 INTO @current_id",
|
||||
"SELECT id FROM users INTO @current_id LIMIT 1",
|
||||
"SELECT * FROM users INTO OUTFILE '/tmp/x'",
|
||||
"SELECT * FROM users FOR UPDATE",
|
||||
"SELECT * FROM users FOR SHARE",
|
||||
"SELECT * FROM users LOCK IN SHARE MODE",
|
||||
"SELECT SLEEP(10)",
|
||||
"SELECT GET_LOCK('x', 1)",
|
||||
"SELECT LAST_INSERT_ID()",
|
||||
"SELECT my_custom_udf(1)",
|
||||
"SELECT secret_schema.fn(1) FROM t",
|
||||
"SELECT id FROM users WHERE EXISTS (SELECT SLEEP(1))",
|
||||
"WITH x AS (SELECT SLEEP(1)) SELECT 1",
|
||||
"SELECT ROW_NUMBER() OVER (ORDER BY id) FROM users",
|
||||
"SELECT id FROM users; SELECT 1",
|
||||
"EXPLAIN ANALYZE SELECT * FROM users",
|
||||
"SELEC * FORM users",
|
||||
"SET autocommit = 1",
|
||||
] {
|
||||
assert_eq!(
|
||||
prove_read_only_for_database(sql, DatabaseType::Mysql),
|
||||
ReadProof::Unproven,
|
||||
"expected unproven: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_proof_accepts_plain_reads_and_rejects_side_effects() {
|
||||
for sql in [
|
||||
"SELECT * FROM users",
|
||||
"WITH x AS (SELECT 1) SELECT * FROM x",
|
||||
"SELECT * FROM t WHERE id IN (SELECT id FROM u)",
|
||||
"SELECT coalesce(a, b) FROM t",
|
||||
"SELECT split_part(email, '@', 2) FROM users",
|
||||
"EXPLAIN SELECT 1",
|
||||
"SELECT 1",
|
||||
] {
|
||||
assert_eq!(
|
||||
prove_read_only_for_database(sql, DatabaseType::Postgres),
|
||||
ReadProof::ProvenReadOnly,
|
||||
"expected proven: {sql}"
|
||||
);
|
||||
}
|
||||
for sql in [
|
||||
"WITH w AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM w",
|
||||
"SELECT * INTO new_t FROM t",
|
||||
"SELECT * FROM t FOR UPDATE",
|
||||
// FOR SHARE parses into query.locks like FOR UPDATE; the other two
|
||||
// PG lock strengths (FOR NO KEY UPDATE / FOR KEY SHARE) fail the
|
||||
// parse itself in sqlparser 0.62 and stay Unproven fail-closed.
|
||||
"SELECT * FROM t FOR SHARE",
|
||||
"SELECT * FROM t FOR NO KEY UPDATE",
|
||||
"SELECT * FROM t FOR KEY SHARE",
|
||||
"SELECT nextval('seq')",
|
||||
"SELECT setval('seq', 1)",
|
||||
"SELECT pg_sleep(1)",
|
||||
"SELECT lo_import('/etc/passwd')",
|
||||
"EXPLAIN ANALYZE SELECT 1",
|
||||
"SELECT my_udf()",
|
||||
"SELECT id, row_number() OVER () FROM t",
|
||||
"SELECT 1; SELECT 2",
|
||||
] {
|
||||
assert_eq!(
|
||||
prove_read_only_for_database(sql, DatabaseType::Postgres),
|
||||
ReadProof::Unproven,
|
||||
"expected unproven: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_visitor_covers_from_subqueries_ctes_lateral_and_order_by() {
|
||||
// visit_expressions skips FROM subqueries; the proof's visitor must not.
|
||||
for sql in [
|
||||
"SELECT * FROM (SELECT SLEEP(1)) AS x",
|
||||
"SELECT * FROM t ORDER BY SLEEP(1)",
|
||||
"SELECT a FROM t, LATERAL (SELECT my_udf() FROM u) d",
|
||||
"SELECT * FROM generate_series(1, 10)",
|
||||
] {
|
||||
assert_eq!(
|
||||
prove_read_only_for_database(sql, DatabaseType::Postgres),
|
||||
ReadProof::Unproven,
|
||||
"expected unproven: {sql}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
prove_read_only_for_database("SELECT * FROM (SELECT 1) AS x ORDER BY md5(id)", DatabaseType::Postgres),
|
||||
ReadProof::ProvenReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_only_applies_to_enabled_dialects() {
|
||||
// Oracle keeps its own lexical classifier; the gate in query.rs must
|
||||
// never route it through the generic proof.
|
||||
assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Oracle), ReadProof::Unproven);
|
||||
// Family members without manual-transaction UI stay unproven in v1.
|
||||
assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Doris), ReadProof::Unproven);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_select_statements() {
|
||||
assert_eq!(classify_sql_risk("SELECT * FROM users", "postgres").unwrap(), SqlRisk::ReadOnly);
|
||||
|
||||
Reference in new issue
Block a user