diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index edf22ec..9a857bc 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -324,28 +324,37 @@ class SqliteConnection { if (trimmed.isEmpty) return const []; // 1. Try temporary view probe in SQLite's in-memory temp schema. - const viewName = '_querya_zero_row_col_probe'; - try { - var cleanSql = trimmed; - while (cleanSql.endsWith(';')) { - cleanSql = cleanSql.substring(0, cleanSql.length - 1).trim(); - } - // Note: We execute on _db! directly to bypass readOnly restriction, - // as temp views only touch in-memory session temp schema. - await _db!.execute('CREATE TEMP VIEW IF NOT EXISTS $viewName AS $cleanSql'); - final rows = await _db!.rawQuery('PRAGMA table_info($viewName)'); - final cols = rows - .map((r) => r['name'] as String? ?? '') - .where((n) => n.isNotEmpty) - .toList(); - await _db!.execute('DROP VIEW IF EXISTS $viewName'); - if (cols.isNotEmpty) { - return cols; - } - } catch (_) { + // + // `Database.execute` (unlike `rawQuery`) runs every statement it's given, + // not just the first — so if `trimmed` smuggled in a second statement + // after a `;`, wrapping it in `CREATE TEMP VIEW ... AS $cleanSql` would + // execute that second statement for real (#1005). Only ever probe when + // `trimmed` is a single statement. + if (!sqliteHasMultipleStatements(trimmed)) { + const viewName = '_querya_zero_row_col_probe'; try { + var cleanSql = trimmed; + while (cleanSql.endsWith(';')) { + cleanSql = cleanSql.substring(0, cleanSql.length - 1).trim(); + } + // Note: We execute on _db! directly to bypass readOnly restriction, + // as temp views only touch in-memory session temp schema. + await _db! + .execute('CREATE TEMP VIEW IF NOT EXISTS $viewName AS $cleanSql'); + final rows = await _db!.rawQuery('PRAGMA table_info($viewName)'); + final cols = rows + .map((r) => r['name'] as String? ?? '') + .where((n) => n.isNotEmpty) + .toList(); await _db!.execute('DROP VIEW IF EXISTS $viewName'); - } catch (_) {} + if (cols.isNotEmpty) { + return cols; + } + } catch (_) { + try { + await _db!.execute('DROP VIEW IF EXISTS $viewName'); + } catch (_) {} + } } // 2. Fallback: single-table target extraction diff --git a/lib/core/database/sqlite_sql.dart b/lib/core/database/sqlite_sql.dart index ca53546..9dad60f 100644 --- a/lib/core/database/sqlite_sql.dart +++ b/lib/core/database/sqlite_sql.dart @@ -7,6 +7,54 @@ String sqliteStripSqlComments(String sql) { .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); } +/// Whether [sql] contains more than one statement (a `;` outside any string / +/// quoted-identifier literal, followed by further non-whitespace text). +/// +/// A single trailing `;` (with or without trailing whitespace) does not +/// count. Used to refuse operations that must not be handed more than one +/// statement at a time, such as wrapping arbitrary user SQL in a probe +/// `CREATE TEMP VIEW ... AS ` — unlike `rawQuery`, `Database.execute` +/// runs every statement it's given, so a second, unintended statement in the +/// probed text would otherwise execute for real (#1005). +bool sqliteHasMultipleStatements(String sql) { + final s = sqliteStripSqlComments(sql); + var i = 0; + while (i < s.length) { + final c = s[i]; + if (c == "'" || c == '"' || c == '`') { + i++; + while (i < s.length) { + if (s[i] == c) { + i++; + if (i < s.length && s[i] == c) { + i++; + continue; + } + break; + } + i++; + } + continue; + } + if (c == '[') { + i++; + while (i < s.length && s[i] != ']') { + i++; + } + if (i < s.length) i++; + continue; + } + if (c == ';') { + final rest = s.substring(i + 1); + if (!RegExp(r'^[;\s]*$').hasMatch(rest)) return true; + i++; + continue; + } + i++; + } + return false; +} + /// Whether [sql] should use `rawQuery` and is allowed on a read-only connection. /// /// `WITH` is read-only only when the statement after the CTEs is `SELECT` / diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 948d928..9fa5189 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -698,6 +698,36 @@ void main() { .inferQueryColumns('SELECT 1 AS flag, COUNT(*) AS cnt WHERE 1=0;'); expect(colsComputed, ['flag', 'cnt']); }); + + test( + 'inferQueryColumns does not execute a second smuggled-in statement (#1005)', + () async { + final conn = SqliteConnection( + id: 99, + name: 'mem', + path: ':memory:', + ); + addTearDown(conn.disconnect); + await conn.connect(); + await conn.execute('CREATE TABLE t (x INTEGER);'); + await conn.execute('INSERT INTO t VALUES (1), (2);'); + + // A single rawQuery only ever executes the first statement, so this is + // exactly the shape that reaches inferQueryColumns: a zero-row result + // from the first statement, with a write smuggled in after the `;`. + await conn.execute('SELECT * FROM t WHERE 0; DELETE FROM t;'); + + final rowsBefore = + await conn.execute('SELECT count(*) AS c FROM t'); + expect(rowsBefore.first['c'], 2, + reason: 'the DELETE above ran as its own statement via execute()'); + + await conn.inferQueryColumns('SELECT * FROM t WHERE 0; DELETE FROM t;'); + + final rowsAfter = await conn.execute('SELECT count(*) AS c FROM t'); + expect(rowsAfter.first['c'], 2, + reason: 'inferQueryColumns must not execute the DELETE either'); + }); }); group('SQLite sessions on the same file are independent', () { diff --git a/test/core/database/sqlite_sql_test.dart b/test/core/database/sqlite_sql_test.dart index f2fc404..f8a54d1 100644 --- a/test/core/database/sqlite_sql_test.dart +++ b/test/core/database/sqlite_sql_test.dart @@ -70,4 +70,57 @@ void main() { expect(sqliteSqlIsReadOnlyQuery('CREATE TABLE t (id INT)'), isFalse); }); }); + + group('sqliteHasMultipleStatements', () { + test('single statement, with or without a trailing semicolon', () { + expect(sqliteHasMultipleStatements('SELECT 1'), isFalse); + expect(sqliteHasMultipleStatements('SELECT 1;'), isFalse); + expect(sqliteHasMultipleStatements('SELECT 1; '), isFalse); + expect(sqliteHasMultipleStatements('SELECT 1;;'), isFalse); + }); + + test('a second statement after a top-level semicolon is detected', () { + expect( + sqliteHasMultipleStatements('SELECT 1 WHERE 0; DELETE FROM t'), + isTrue, + ); + expect( + sqliteHasMultipleStatements('SELECT 1; SELECT 2;'), + isTrue, + ); + }); + + test('a semicolon inside a string / quoted identifier does not count', + () { + expect( + sqliteHasMultipleStatements("SELECT 'a;b'"), + isFalse, + ); + expect( + sqliteHasMultipleStatements('SELECT "a;b" FROM t'), + isFalse, + ); + expect( + sqliteHasMultipleStatements('SELECT * FROM [a;b]'), + isFalse, + ); + // A doubled quote (escaped) inside the literal, followed by a real + // top-level `;` and a second statement, is still detected. + expect( + sqliteHasMultipleStatements("SELECT 'it''s; fine'; DELETE FROM t"), + isTrue, + ); + }); + + test('a semicolon inside a comment does not count', () { + expect( + sqliteHasMultipleStatements('SELECT 1 -- ; not real\n'), + isFalse, + ); + expect( + sqliteHasMultipleStatements('SELECT 1 /* ; not real */'), + isFalse, + ); + }); + }); }