diff --git a/lib/core/database/sql_mutation_classifier.dart b/lib/core/database/sql_mutation_classifier.dart index b3366ddd..3f810118 100644 --- a/lib/core/database/sql_mutation_classifier.dart +++ b/lib/core/database/sql_mutation_classifier.dart @@ -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; +} diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 504b6128..089ded66 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -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'; @@ -612,6 +613,22 @@ class GenericSqlWorkspaceState extends material.State { // 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, diff --git a/test/core/database/sql_mutation_classifier_test.dart b/test/core/database/sql_mutation_classifier_test.dart index 717be9b3..4033b5fc 100644 --- a/test/core/database/sql_mutation_classifier_test.dart +++ b/test/core/database/sql_mutation_classifier_test.dart @@ -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); + }); + }); }