Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions lib/core/database/sql_mutation_classifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,61 @@ bool containsMutatingSql(String sql, {bool backslashEscapes = true}) =>
sql,
backslashEscapes: backslashEscapes,
).any((s) => isMutatingSqlStatement(s, backslashEscapes: backslashEscapes));

/// Statements other than DML / DDL that still write, run code or touch the
/// server's files.
const _readOnlyRefusedFirstWords = {
'CALL', 'DO', 'COPY', 'LOAD', 'REFRESH', 'COMMENT', 'VACUUM', 'REINDEX',
'CLUSTER', 'LOCK', 'IMPORT',
};

final _readOnlyEscape = RegExp(
r'\bREAD\s+WRITE\b|'
r'\b(?:DEFAULT_)?TRANSACTION_READ_ONLY\b|\bTX_READ_ONLY\b|\bREAD_ONLY\b',
);
final _setConfig = RegExp(r'\bSET_CONFIG\s*\(');
final _explainAnalyze = RegExp(r'^EXPLAIN\s+(?:\(?[^)]*\b)?ANALY[SZ]E\b');

/// Why the Read-only mode of the SQL editor refuses [statement], or null when
/// it may run. Covers what [isMutatingSqlStatement] does and the ways a script
/// could switch the session back to read-write (`SET ... transaction_read_only`,
/// `BEGIN READ WRITE`, `SET TRANSACTION READ WRITE`, `set_config(...)`), plus
/// `EXPLAIN ANALYZE` of a write and statements that run code or write files.
String? readOnlyRefusal(String statement, {bool backslashEscapes = true}) {
final upper = DestructiveSqlDetector.stripCommentsAndStrings(
statement,
backslashEscapes: backslashEscapes,
).trim().toUpperCase();
if (upper.isEmpty) return null;
final first = _firstWord.firstMatch(upper)?.group(1);
if (first == null) return null;
if (_explainAnalyze.hasMatch(upper) && _dataModifyingCte.hasMatch(upper)) {
return 'EXPLAIN ANALYZE of a data-modifying statement would run it';
}
if (isMutatingSqlStatement(statement, backslashEscapes: backslashEscapes)) {
return 'it changes data or schema';
}
if (_readOnlyRefusedFirstWords.contains(first)) {
return '$first can write or run code';
}
// `READ_ONLY` as a column name in a SELECT is fine; only the statements that
// set a session or transaction mode count.
final setsMode = first == 'SET' || first == 'BEGIN' || first == 'START';
if ((setsMode && _readOnlyEscape.hasMatch(upper)) ||
_setConfig.hasMatch(upper)) {
return 'it would switch the session out of read-only';
}
return null;
}

/// The first reason in [sql] (a script) that Read-only mode refuses, or null.
String? readOnlyScriptRefusal(String sql, {bool backslashEscapes = true}) {
for (final statement in DestructiveSqlDetector.splitStatements(
sql,
backslashEscapes: backslashEscapes,
)) {
final why = readOnlyRefusal(statement, backslashEscapes: backslashEscapes);
if (why != null) return why;
}
return null;
}
17 changes: 17 additions & 0 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:querya_desktop/core/database/sql_mutation_classifier.dart';
import 'package:querya_desktop/shared/report_error.dart';
import 'package:flutter/widgets.dart';
import 'package:querya_desktop/core/erd/erd_saved_layout.dart';
Expand Down Expand Up @@ -612,6 +613,22 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
// whole script is one statement.
final sent = texts.length == 1 ? [runSql] : texts;

// Read-only is a guarantee, not a session default: the server setting can
// be switched off by a script, so the editor does not send such a script.
if (widget.isReadOnly) {
final why = readOnlyScriptRefusal(
scope,
backslashEscapes: widget.dialect.backslashEscapesQuotes,
);
if (why != null) {
setState(() {
session.error = 'Read-only mode is on and $why. '
'Turn Read-only off to run it.';
});
return;
}
}

final safeToProceed = await confirmDiscardTableEditsIfDirty(
context: context,
buffer: session.stagingBuffer,
Expand Down
52 changes: 52 additions & 0 deletions test/core/database/sql_mutation_classifier_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,56 @@ void main() {
expect(containsMutatingSql(sql, backslashEscapes: false), isTrue);
});
});

group('readOnlyRefusal (#1773)', () {
test('refuses writes and every way back to read-write', () {
for (final sql in [
'INSERT INTO t VALUES (1)',
'DROP TABLE t',
'BEGIN READ WRITE',
'START TRANSACTION READ WRITE',
'SET TRANSACTION READ WRITE',
'SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE',
'SET default_transaction_read_only = off',
'SET SESSION transaction_read_only = off',
'SET SESSION TRANSACTION READ WRITE',
'SET GLOBAL read_only = 0',
"SELECT set_config('default_transaction_read_only', 'off', false)",
'EXPLAIN ANALYZE DELETE FROM t',
'EXPLAIN (ANALYZE, VERBOSE) UPDATE t SET a = 1',
'WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d',
'CALL do_things()',
"DO \$\$ BEGIN DELETE FROM t; END \$\$",
"COPY t TO '/tmp/x'",
'VACUUM t',
]) {
expect(readOnlyRefusal(sql), isNotNull, reason: sql);
}
});

test('lets reading and harmless settings through', () {
for (final sql in [
'SELECT * FROM t',
'SELECT read_only FROM flags',
"SHOW VARIABLES LIKE 'read_only'",
'SET search_path TO public',
'SET TRANSACTION READ ONLY',
'BEGIN READ ONLY',
'BEGIN',
'EXPLAIN SELECT 1',
'EXPLAIN DELETE FROM t',
'EXPLAIN ANALYZE SELECT 1',
"SELECT 'BEGIN READ WRITE' AS s",
'-- BEGIN READ WRITE\nSELECT 1',
]) {
expect(readOnlyRefusal(sql), isNull, reason: sql);
}
});

test('a script is refused when any statement is', () {
expect(readOnlyScriptRefusal('SELECT 1; SET default_transaction_read_only = off; DELETE FROM t'),
isNotNull);
expect(readOnlyScriptRefusal('SELECT 1; SELECT 2'), isNull);
});
});
}
Loading