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
53 changes: 44 additions & 9 deletions lib/core/database/destructive_sql_detector.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:querya_desktop/core/database/sql_lexical_scanner.dart';
import 'package:querya_desktop/core/database/sql_statement_splitter.dart';

/// Categorization of destructive SQL / Mongo / Redis operations that can
Expand Down Expand Up @@ -267,7 +268,12 @@ abstract final class DestructiveSqlDetector {

/// Offset after the single-quoted literal whose body starts at [start]
/// (`''` and backslash escapes); [len] when it is never closed.
static int _skipStringLiteral(String sql, int start, int len) {
static int _skipStringLiteral(
String sql,
int start,
int len, {
required bool backslashEscapes,
}) {
var i = start;
while (i < len) {
final c = sql.codeUnitAt(i);
Expand All @@ -277,7 +283,7 @@ abstract final class DestructiveSqlDetector {
} else {
return i + 1; // closing quote
}
} else if (c == _chBackslash && i + 1 < len) {
} else if (backslashEscapes && c == _chBackslash && i + 1 < len) {
i += 2; // escaped char
} else {
i++;
Expand All @@ -292,7 +298,14 @@ abstract final class DestructiveSqlDetector {
/// Scans code units with the `_skip*` helpers (no per-character strings, no
/// regex, no copy of the rest of the script at every `$`), and appends the
/// text between tokens in runs (#1511).
static String stripCommentsAndStrings(String sql) {
///
/// [backslashEscapes] is whether `\'` escapes a quote (MySQL; the default).
/// With `false` (PostgreSQL, SQLite) a backslash is an ordinary character,
/// except inside an `E'...'` string, as the server reads it.
static String stripCommentsAndStrings(
String sql, {
bool backslashEscapes = true,
}) {
final buffer = StringBuffer();
final len = sql.length;
var i = 0;
Expand Down Expand Up @@ -322,7 +335,13 @@ abstract final class DestructiveSqlDetector {
}
replacement = "''";
} else if (c == _chQuote) {
after = _skipStringLiteral(sql, i + 1, len);
after = _skipStringLiteral(
sql,
i + 1,
len,
backslashEscapes:
backslashEscapes || SqlLexicalScanner.isEscapeStringPrefix(sql, i),
);
replacement = "''";
} else {
i++;
Expand All @@ -341,17 +360,33 @@ abstract final class DestructiveSqlDetector {

/// Splits an SQL query into individual statements on `;`, taking into account
/// comments and string literals.
static List<String> splitStatements(String sql) => [
for (final span in SqlStatementSplitter.spans(sql)) span.textIn(sql),
///
/// [backslashEscapes]: see [stripCommentsAndStrings].
static List<String> splitStatements(
String sql, {
bool backslashEscapes = true,
}) =>
[
for (final span
in SqlStatementSplitter.spans(sql, backslashEscapes: backslashEscapes))
span.textIn(sql),
];

/// Inspects [sql] and returns any detected destructive operations.
static DestructiveSqlInspectionResult inspect(String sql) {
final statements = splitStatements(sql);
///
/// [backslashEscapes]: see [stripCommentsAndStrings].
static DestructiveSqlInspectionResult inspect(
String sql, {
bool backslashEscapes = true,
}) {
final statements = splitStatements(sql, backslashEscapes: backslashEscapes);
final operations = <DestructiveSqlOperation>[];

for (final rawStmt in statements) {
final sanitized = stripCommentsAndStrings(rawStmt).trim();
final sanitized = stripCommentsAndStrings(
rawStmt,
backslashEscapes: backslashEscapes,
).trim();
if (sanitized.isEmpty) continue;

// 1. DROP DATABASE
Expand Down
4 changes: 2 additions & 2 deletions lib/core/database/sql_lexical_scanner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ abstract final class SqlLexicalScanner {
}

if (code == _chSingleQuote) {
final escapes = backslashEscapes || _isEscapeStringPrefix(sql, i);
final escapes = backslashEscapes || isEscapeStringPrefix(sql, i);
var j = i + 1;
while (j < len) {
final curr = sql.codeUnitAt(j);
Expand Down Expand Up @@ -169,7 +169,7 @@ abstract final class SqlLexicalScanner {

/// Whether the quote at [quote] opens a PostgreSQL escape string: `E'` or
/// `e'`, the `E` not being the end of a longer word (`TYPE'...'`).
static bool _isEscapeStringPrefix(String sql, int quote) {
static bool isEscapeStringPrefix(String sql, int quote) {
if (quote < 1) return false;
final e = sql.codeUnitAt(quote - 1);
if (e != 0x45 && e != 0x65) return false;
Expand Down
19 changes: 13 additions & 6 deletions lib/core/database/sql_mutation_classifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ final _dataModifyingCte = RegExp(r'\b(INSERT|UPDATE|DELETE|MERGE)\b');
///
/// Comments and string literals are ignored. `WITH ...` statements count when
/// a data-modifying statement appears inside them.
bool isMutatingSqlStatement(String statement) {
final sanitized =
DestructiveSqlDetector.stripCommentsAndStrings(statement).trim();
///
/// [backslashEscapes]: whether `\'` escapes a quote (MySQL; the default), see
/// [DestructiveSqlDetector.stripCommentsAndStrings].
bool isMutatingSqlStatement(String statement, {bool backslashEscapes = true}) {
final sanitized = DestructiveSqlDetector.stripCommentsAndStrings(
statement,
backslashEscapes: backslashEscapes,
).trim();
if (sanitized.isEmpty) return false;
final upper = sanitized.toUpperCase();
final first = _firstWord.firstMatch(upper)?.group(1);
Expand All @@ -25,6 +30,8 @@ bool isMutatingSqlStatement(String statement) {
}

/// Whether the SQL script [sql] contains at least one mutating statement.
bool containsMutatingSql(String sql) => DestructiveSqlDetector
.splitStatements(sql)
.any(isMutatingSqlStatement);
bool containsMutatingSql(String sql, {bool backslashEscapes = true}) =>
DestructiveSqlDetector.splitStatements(
sql,
backslashEscapes: backslashEscapes,
).any((s) => isMutatingSqlStatement(s, backslashEscapes: backslashEscapes));
129 changes: 27 additions & 102 deletions lib/core/database/sql_statement_splitter.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:querya_desktop/core/database/sql_lexical_scanner.dart';

/// One statement of a script, as offsets into the script.
class SqlStatementSpan {
Expand All @@ -24,21 +25,8 @@ class SqlStatementSpan {
/// literals, quoted identifiers, comments and PostgreSQL dollar quotes do not
/// end a statement.
abstract final class SqlStatementSplitter {
/// `$tag$` or `$$`, matched at an offset (no `^`: it would only match at the
/// start of the script). One compiled instance for every dollar sign.
static final RegExp _dollarTag = RegExp(r'\$([a-zA-Z0-9_]*)\$');

static const int _chDash = 0x2D;
static const int _chSlash = 0x2F;
static const int _chAsterisk = 0x2A;
static const int _chDollar = 0x24;
static const int _chSingleQuote = 0x27;
static const int _chDoubleQuote = 0x22;
static const int _chBacktick = 0x60;
static const int _chSemicolon = 0x3B;
static const int _chBackslash = 0x5C;
static const int _chLf = 0x0A;
static const int _chCr = 0x0D;

/// Default script size threshold (64 KB) above which [spansAsync] offloads
/// parsing to a background isolate via [compute].
Expand All @@ -50,17 +38,28 @@ abstract final class SqlStatementSplitter {
static Future<List<SqlStatementSpan>> spansAsync(
String sql, {
int isolateThreshold = defaultIsolateThreshold,
bool backslashEscapes = true,
}) async {
if (sql.length <= isolateThreshold) {
return spans(sql);
return spans(sql, backslashEscapes: backslashEscapes);
}
return compute(spans, sql);
return compute(_spansInIsolate, (sql, backslashEscapes));
}

static List<SqlStatementSpan> spans(String sql) {
static List<SqlStatementSpan> _spansInIsolate((String, bool) args) =>
spans(args.$1, backslashEscapes: args.$2);

/// [backslashEscapes] says whether `\'` escapes a quote inside `'...'`, as
/// it does in MySQL (the default, and what an unknown dialect gets). Pass
/// `dialect.backslashEscapesQuotes` when the dialect is known: reading
/// `'a\'` as an unfinished literal in PostgreSQL or SQLite hides the `;`
/// after it, and the statement the server then runs.
static List<SqlStatementSpan> spans(
String sql, {
bool backslashEscapes = true,
}) {
final out = <SqlStatementSpan>[];
final len = sql.length;
var i = 0;
var segmentStart = 0;

// Statement starts only move forward, so the line of each one is found by
Expand Down Expand Up @@ -90,93 +89,19 @@ abstract final class SqlStatementSplitter {
}
}

while (i < len) {
final code = sql.codeUnitAt(i);

// Line comment: runs to the end of the line.
if (code == _chDash && i + 1 < len && sql.codeUnitAt(i + 1) == _chDash) {
while (i < len &&
sql.codeUnitAt(i) != _chLf &&
sql.codeUnitAt(i) != _chCr) {
i++;
}
continue;
}

// Block comment.
if (code == _chSlash &&
i + 1 < len &&
sql.codeUnitAt(i + 1) == _chAsterisk) {
i += 2;
while (i + 1 < len &&
!(sql.codeUnitAt(i) == _chAsterisk &&
sql.codeUnitAt(i + 1) == _chSlash)) {
i++;
}
i = i + 1 < len ? i + 2 : len;
continue;
}

// Dollar quote: $tag$ ... $tag$.
if (code == _chDollar) {
final match = _dollarTag.matchAsPrefix(sql, i);
if (match != null) {
final tag = match.group(0)!;
final close = sql.indexOf(tag, i + tag.length);
i = close == -1 ? len : close + tag.length;
continue;
// A `;` ends a statement only in code: literals, quoted identifiers,
// comments and dollar quotes are the scanner's other segments.
for (final segment in SqlLexicalScanner.segments(
sql,
backslashEscapes: backslashEscapes,
)) {
if (segment.kind != SqlSegmentKind.code) continue;
for (var i = segment.start; i < segment.end; i++) {
if (sql.codeUnitAt(i) == _chSemicolon) {
close(i);
segmentStart = i + 1;
}
}

// String literal, with doubled quotes and backslash escapes.
if (code == _chSingleQuote) {
i++;
while (i < len) {
final curr = sql.codeUnitAt(i);
if (curr == _chSingleQuote) {
if (i + 1 < len && sql.codeUnitAt(i + 1) == _chSingleQuote) {
i += 2;
} else {
i++;
break;
}
} else if (curr == _chBackslash && i + 1 < len) {
i += 2;
} else {
i++;
}
}
continue;
}

// Quoted identifier: "name" (PostgreSQL) or `name` (MySQL).
if (code == _chDoubleQuote || code == _chBacktick) {
final quoteChar = code;
i++;
while (i < len) {
final curr = sql.codeUnitAt(i);
if (curr == quoteChar) {
if (i + 1 < len && sql.codeUnitAt(i + 1) == quoteChar) {
i += 2;
} else {
i++;
break;
}
} else {
i++;
}
}
continue;
}

if (code == _chSemicolon) {
close(i);
i++;
segmentStart = i;
continue;
}

i++;
}
close(len);
return out;
Expand Down
7 changes: 7 additions & 0 deletions lib/core/database/table_mutation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ enum SqlDialect {
sqlite,
}

extension SqlDialectLexing on SqlDialect {
/// Whether `\'` inside `'...'` is an escaped quote. It is in MySQL; in
/// PostgreSQL and SQLite a backslash is an ordinary character and `'C:\'`
/// is a whole literal (PostgreSQL's `E'...'` strings are the exception).
bool get backslashEscapesQuotes => this == SqlDialect.mysql;
}

/// Type of DML mutation.
enum MutationType {
insert,
Expand Down
22 changes: 16 additions & 6 deletions lib/core/mcp/mcp_sql_guard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,17 @@ abstract final class McpSqlGuard {
/// Returns `null` when [sql] may run, or why it may not: the rule that
/// refused it (stable, for the audit log) and the message for the model.
static McpSqlRefusal? refusal(String sql, SqlDialect dialect) {
final statements = DestructiveSqlDetector.splitStatements(sql)
.where((s) => DestructiveSqlDetector.stripCommentsAndStrings(s)
.trim()
.isNotEmpty)
// Read the text the way the server does: in PostgreSQL and SQLite `'a\'`
// is a whole literal, so a `;` after it ends the statement.
final escapes = dialect.backslashEscapesQuotes;
final statements = DestructiveSqlDetector.splitStatements(
sql,
backslashEscapes: escapes,
)
.where((s) => DestructiveSqlDetector.stripCommentsAndStrings(
s,
backslashEscapes: escapes,
).trim().isNotEmpty)
.toList();
if (statements.isEmpty) return const McpSqlRefusal('empty_query', 'The query is empty.');
if (statements.length > 1) {
Expand All @@ -90,7 +97,10 @@ abstract final class McpSqlGuard {
RegExp(r'/\*M?!').hasMatch(statement)) {
return const McpSqlRefusal('mysql_executable_comment', 'MySQL executable comments (/*! ... */) are not allowed.');
}
final upper = DestructiveSqlDetector.stripCommentsAndStrings(statement)
final upper = DestructiveSqlDetector.stripCommentsAndStrings(
statement,
backslashEscapes: escapes,
)
.trim()
.replaceAll(RegExp(r';\s*$'), '')
.toUpperCase();
Expand All @@ -113,7 +123,7 @@ abstract final class McpSqlGuard {
}
return null;
}
if (isMutatingSqlStatement(statement)) {
if (isMutatingSqlStatement(statement, backslashEscapes: escapes)) {
return const McpSqlRefusal('data_modifying', 'Data-modifying statements are not allowed over MCP.');
}
if (_selectSideEffects.hasMatch(upper)) {
Expand Down
Loading
Loading